diff --git a/.gitignore b/.gitignore index 878c2cd5b1..8e0a82dc27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,116 @@ -*.iml -.gradle -/local.properties -.idea +# Mac OS .DS_Store -/build -/captures -.externalNativeBuild -.cxx + +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +releases/ +config/detekt/reports/ +/new_releases.tmp + +# Gradle files +.gradle/ +build/ +gradlew +gradlew.bat + +# Local configuration file (sdk path, etc) local.properties + +# Log Files +*.log + +# Remote Detek config +config/detekt/config.yml + +# Fastlane +fastlane/README.md +fastlane/report.xml + +# Keystore +keystore/* + +# Private properties +private.properties + +# Sentry properties +sentry.properties + +# Test users credentials +**/users.json +**/internal_api.json +**/internal_apis.json + +# UI Tests Assets backup lockfile +scripts/uitests/AssetsFile.lock.bak + +# # # # # # # # # +# IDEA SETTINGS # +# # # # # # # # # + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries/** +# Do not exclude project Dictionary +!.idea/**/dictionaries/project.xml +.idea/androidTestResultsUserPreferences.xml +.idea/assetWizardSettings.xml +.idea/deploymentTargetDropDown.xml +.idea/deploymentTargetSelector.xml +.idea/vcs.xml +.idea/**/shelf +.idea/runConfigurations.xml +.idea/misc.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +.idea/artifacts +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# Git Toolbox plugin +.idea/git_toolbox_prj.xml + +# Platform specific files +.idea/.name diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..2a7a47916e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,449 @@ +default: + image: ${PROTON_CI_REGISTRY}/android-shared/docker-android/oci:v2.1.9 + interruptible: true + tags: + - shared-small + +before_script: + - if [[ -f /load-env.sh ]]; then source /load-env.sh; fi + - bundle config set path ${BUNDLE_GEM_PATH} + - bundle config set without 'production' + - bundle install + +variables: + # Clean up cache when extraction fails + FF_CLEAN_UP_FAILED_CACHE_EXTRACT: "true" + # Output upload and download progress every 5 seconds + TRANSFER_METER_FREQUENCY: "5s" + # Use no compression for artifacts + ARTIFACT_COMPRESSION_LEVEL: "fastest" + # Use low compression for caches + CACHE_COMPRESSION_LEVEL: "fast" + # Gem path + BUNDLE_GEM_PATH: 'vendor/ruby' + +# Makes sure we do not create separate merge request and branch pipelines. +# See https://docs.gitlab.com/ee/ci/yaml/workflow.html#switch-between-branch-pipelines-and-merge-request-pipelines +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + when: never + - if: '$CI_COMMIT_TAG != null' + when: never + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + +.gradle-cache-dev: &gradle-cache-dev + key: + prefix: gradle-cache-dev + files: + - gradle/wrapper/gradle-wrapper.properties + paths: + - .gradle + policy: pull + +.gradle-cache-alpha: &gradle-cache-alpha + key: + prefix: gradle-cache-alpha + files: + - gradle/wrapper/gradle-wrapper.properties + paths: + - .gradle + policy: pull + +.gradle-cache-prod: &gradle-cache-prod + key: + prefix: gradle-cache-prod + files: + - gradle/wrapper/gradle-wrapper.properties + paths: + - .gradle + policy: pull + +.ruby-cache: &ruby-cache + key: + prefix: ruby-cache + files: + - Gemfile.lock + paths: + - ${BUNDLE_GEM_PATH} + policy: pull + +stages: + - analyse + - danger-review + - localise + - build + - startReview + - test + - deploy + - tag + - publish + - stopReview + +detekt: + stage: analyse + cache: + - <<: *gradle-cache-dev + policy: !reference [ .cache-policy, cache, policy ] + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + tags: + - shared-large + script: + - bundle exec fastlane analyse + artifacts: + expire_in: 1 month + reports: + codequality: config/detekt/reports/mergedReport.json + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"' + when: manual + allow_failure: true + - when: always + +danger-review: + stage: danger-review + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + tags: + - shared-small + when: always + script: + - bundle exec danger --fail-on-errors=false + allow_failure: true + variables: + DANGER_GITLAB_API_TOKEN: $DANGER_GITLAB_API_TOKEN + interruptible: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +i18n-send-crowdin: + stage: localise + extends: .i18n-sync-crowdin-shared + tags: + - shared-small + variables: + I18N_FILTER_OUT_ITEMS: 'proton-libs' + I18N_SYNC_CROWDIN_PROJECT: 'android-mail-new' + I18N_SYNC_BRANCH: 'main' + +i18n-commit-locales: + stage: localise + extends: .i18n-commit-locales-shared + tags: + - shared-small + variables: + I18N_COMMIT_CROWDIN_PROJECT: 'android-mail-new' + I18N_COMMIT_BRANCH_PUSH: 'main' + I18N_COMMIT_BRANCH_ALLOWED: 'main' + +build_dev_debug: + extends: .build_job + cache: + - <<: *gradle-cache-dev + policy: !reference [ .cache-policy, cache, policy ] + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - base64 -d - < "$GOOGLE_SERVICES_JSON_FILE" > app/google-services.json + - bundle exec fastlane setupUiTestsAssets + - bundle exec fastlane assembleDevDebug + - bundle exec fastlane assembleDevDebugAndroidTest + +build_alpha_release: + extends: .build_job + cache: + - <<: *gradle-cache-alpha + policy: !reference [ .cache-policy, cache, policy ] + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - base64 -d - < "$PRIVATE_PROPERTIES_FILE" > private.properties + - base64 -d - < "$SENTRY_PROPERTIES_FILE" > sentry.properties + - base64 -d - < "$PROTON_RELEASE_KEYSTORE_BASE64" > keystore/ProtonMail.keystore + - base64 -d - < "$GOOGLE_SERVICES_JSON_FILE" > app/google-services.json + - bundle exec fastlane assembleAlphaRelease + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + # Always build on merge requests + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "parent_pipeline" + when: never + +build_prod_release: + extends: .build_job + cache: + - <<: *gradle-cache-prod + policy: !reference [ .cache-policy, cache, policy ] + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - base64 -d - < "$PRIVATE_PROPERTIES_FILE" > private.properties + - base64 -d - < "$SENTRY_PROPERTIES_FILE" > sentry.properties + - base64 -d - < "$PROTON_RELEASE_KEYSTORE_BASE64" > keystore/ProtonMail.keystore + - base64 -d - < "$GOOGLE_SERVICES_JSON_FILE" > app/google-services.json + - bundle exec fastlane assembleProdRelease + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + # Always build on merge requests + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_PIPELINE_SOURCE == "parent_pipeline" + when: never + +startReview: + stage: startReview + tags: + - shared-small + before_script: + - export REVIEW_APP_ARTIFACT_PATH="app/build/outputs/apk/alpha/release/app-alpha-release.apk" + - echo ${REVIEW_APP_ARTIFACT_PATH} + extends: .startReview + dependencies: + - build_alpha_release + only: + - merge_requests + +stopReview: + stage: stopReview + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + extends: .stopReview + tags: + - shared-small + before_script: [ ] + rules: + # The cleanup is always manual on a Merge Request + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: manual + allow_failure: true + # But it's not needed when not on a Merge Request + - when: never + +run_unit_test: + stage: test + cache: + - <<: *gradle-cache-dev + policy: !reference [ .cache-policy, cache, policy ] + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + tags: + - xlarge-k8s + dependencies: + - build_dev_debug + script: + - base64 -d - < "$GOOGLE_SERVICES_JSON_FILE" > app/google-services.json + - bundle exec fastlane unitTest + - bundle exec fastlane coverageReport + coverage: /TotalLineCoverage.*?(\d{1,3}\.\d{0,2})%/ + interruptible: true + artifacts: + when: always + expire_in: 1 week + paths: + - '**/build/reports/kover/cobertura*.xml' + - './coverage/build/reports/kover/html/' + reports: + junit: ./**/test-results/*/TEST-*.xml + coverage_report: + coverage_format: cobertura + path: '**/build/reports/kover/cobertura*.xml' + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"' + when: manual + allow_failure: true + - when: always + +run_firebase_proton_core_libs_tests: + extends: .firebase_test_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + allow_failure: true # Temporary, as long as we don't have a more specific test environment. + script: + - bundle exec fastlane coreLibsTest + rules: + - if: ('$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH') && ($NIGHTLY_ALPHA_BUILD == "true" || $INTERNAL_ALPHA_RELEASE == "true") + +run_firebase_smoke_tests: + extends: .firebase_test_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - bundle exec fastlane smokeTest + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: ('$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH') || $NIGHTLY_ALPHA_BUILD == "true" + when: never + - when: on_success + +run_firebase_full_regression_tests: + extends: .firebase_test_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - bundle exec fastlane fullRegressionTest + rules: + - if: ('$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH') && ($NIGHTLY_ALPHA_BUILD == "true" || $INTERNAL_ALPHA_RELEASE == "true") + # Allow manual regression test runs on MRs if needed. + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: manual + allow_failure: true + +tag_release: + stage: tag + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + tags: + - shared-small + script: + - bundle exec fastlane tagRelease + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: never + - if: ('$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH') && ($INTERNAL_ALPHA_RELEASE != "true") + when: manual + allow_failure: true + - if: ('$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH') && ($INTERNAL_ALPHA_RELEASE == "true") + +deploy_to_firebase_dev: + extends: .firebase_deploy_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - bundle exec fastlane deployToFirebaseDevGroup + rules: + - !reference [ .firebase_deploy_job, rules ] + - if: $NIGHTLY_ALPHA_BUILD == "true" + when: never + - if: $INTERNAL_ALPHA_RELEASE == "true" + when: never + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: manual + allow_failure: true + +deploy_to_firebase_nightly: + extends: .firebase_deploy_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - bundle exec fastlane deployToFirebaseNightlyGroup + rules: + - !reference [ .firebase_deploy_job, rules ] + - if: $NIGHTLY_ALPHA_BUILD == "true" + +deploy_to_firebase_alpha: + extends: .firebase_deploy_job + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + script: + - bundle exec fastlane deployToFirebaseInternalAlphaGroup + rules: + - !reference [ .firebase_deploy_job, rules ] + - if: $INTERNAL_ALPHA_RELEASE == "true" + +deploy_play_store_internal: + stage: deploy + cache: + - <<: *ruby-cache + policy: !reference [ .cache-policy, cache, policy ] + dependencies: + - build_prod_release + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: never + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + when: manual + allow_failure: true + script: + - base64 -d - < "$PLAY_STORE_SERVICE_ACCOUNT_JSON" > /tmp/play_store_service_account.json + - bundle exec fastlane deployToPlayStoreInternal + +distribute_debug_mr: + stage: deploy + image: $CI_REGISTRY/tpe/test-scripts + dependencies: + - build_dev_debug + rules: + - if: $CI_PIPELINE_SOURCE == 'merge_request_event' + allow_failure: true + before_script: [] + script: + - MAIN_APK=app/build/outputs/apk/dev/debug/app-dev-debug.apk + - TEST_APK=app/build/outputs/apk/androidTest/dev/debug/app-dev-debug-androidTest.apk + - /usr/local/bin/nexus/mr_created_commit_pushed.py + --token "$MAIL_ANDROID_READ_ACCESS_TOKEN" + --component "/Mail/Android" + --file_paths "$MAIN_APK" "$TEST_APK" + --file_names "mail-dev-debug.apk" "mail-dev-debug-test.apk" + +distribute_debug_post_merge: + stage: deploy + image: $CI_REGISTRY/tpe/test-scripts + dependencies: + - build_dev_debug + rules: + - if: '$CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"' + allow_failure: true + before_script: [] + script: + - MAIN_APK=app/build/outputs/apk/dev/debug/app-dev-debug.apk + - TEST_APK=app/build/outputs/apk/androidTest/dev/debug/app-dev-debug-androidTest.apk + - /usr/local/bin/nexus/mr_merged_with_post_merge_pipeline.py + --token "$MAIL_ANDROID_READ_ACCESS_TOKEN" + --component "/Mail/Android" + --file_paths "$MAIN_APK" "$TEST_APK" + --file_names "mail-dev-debug.apk" "mail-dev-debug-test.apk" + +release_publish_github: + stage: publish + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + when: manual + - when: never + script: + - ./tools/private/publish/publish-to-github.sh + tags: + - shared-medium + +include: + # Push the cache if it's the default branch + - rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + local: /ci/cache-policy-push-pull.yml + + # Do not push the cache if it's not the default branch (e.g. MRs) + - rules: + - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH' + local: /ci/cache-policy-pull.yml + + - component: gitlab.protontech.ch/proton/devops/cicd-components/kits/devsecops/generic@0.0.14 + inputs: + stage: analyse + + - component: gitlab.protontech.ch/proton/devops/cicd-components/community/gradle-wrapper/validate@0.0.3 + inputs: + stage: analyse + + - project: 'translations/generator' + ref: master + file: '/jobs/sync-crowdin.gitlab-ci.yml' + + - project: 'translations/generator' + ref: master + file: '/jobs/commit-locales.gitlab-ci.yml' + + - project: 'proton/mobile/android/proton-libs' + ref: main + file: '/ci/templates-shared/appetize-integration.yml' + + - local: '/ci/templates/base-jobs.gitlab-ci.yml' diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521af..0000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 47a9fd3770..0000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -ProtonMail \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..1b6bac1db2 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,144 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..82c2e2f0bb --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8a46..0000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/copyright/ProtonMail.xml b/.idea/copyright/ProtonMail.xml new file mode 100644 index 0000000000..64a0681196 --- /dev/null +++ b/.idea/copyright/ProtonMail.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000000..68c27461af --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/detekt.xml b/.idea/detekt.xml new file mode 100644 index 0000000000..3b3d187580 --- /dev/null +++ b/.idea/detekt.xml @@ -0,0 +1,31 @@ + + + + + + + + + true + true + false + $PROJECT_DIR$/config/detekt/config.yml + $PROJECT_DIR$/detekt-rules/build/libs/detekt-rules.jar + + \ No newline at end of file diff --git a/.idea/externalDependencies.xml b/.idea/externalDependencies.xml new file mode 100644 index 0000000000..10de185b25 --- /dev/null +++ b/.idea/externalDependencies.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 4e3844e80d..0000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000000..d5dc5f0203 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000..631aad0431 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,49 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000000..4cb7457249 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 2a4d5b521d..0000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/connectedAndroidTest.xml b/.idea/runConfigurations/connectedAndroidTest.xml new file mode 100644 index 0000000000..e25fd68316 --- /dev/null +++ b/.idea/runConfigurations/connectedAndroidTest.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/testDebugUnitTest.xml b/.idea/runConfigurations/testDebugUnitTest.xml new file mode 100644 index 0000000000..a13728b5e1 --- /dev/null +++ b/.idea/runConfigurations/testDebugUnitTest.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.idea/saveactions_settings.xml b/.idea/saveactions_settings.xml new file mode 100644 index 0000000000..1f35a75acd --- /dev/null +++ b/.idea/saveactions_settings.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.locale-state.metadata b/.locale-state.metadata new file mode 100644 index 0000000000..a455641a81 --- /dev/null +++ b/.locale-state.metadata @@ -0,0 +1,4 @@ +{ + "project": "android-mail-new", + "locale": "b5ca8a59cdfa9c3c163997527d0276de2cf23e9b" +} \ No newline at end of file diff --git a/.margebot.yml b/.margebot.yml new file mode 100644 index 0000000000..636030ff6a --- /dev/null +++ b/.margebot.yml @@ -0,0 +1,11 @@ +ciFailFast: true + +fastTrack: + allowList: + - amezcua + - ajodlows + - jprueller + - mmeneghel + - mwassell + - nforlini + - sboshkov diff --git a/.publishignore b/.publishignore new file mode 100644 index 0000000000..b5369d4d71 --- /dev/null +++ b/.publishignore @@ -0,0 +1,5 @@ +app/src/uiTest/assets +tools/private/ +docs/ +keystore/ +CODEOWNERS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..630942fbb5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# ProtonMail contributions guidelines + +Every contribution to the project needs to follow some rules. + +**Team decisions are tracked in form of ADRs in /docs folder** + +### Planning + +Every significant change needs to be **discussed with the team** and go through a **planning process**; *this excludes minor contributions, simple bug fixes, or performance improvements that don't impact the app's behavior.* + +If the contribution is from a Proton member, the presence on the team daily standups is preferred. + +### Git best practices + +**Merge/Pull Requests must be of a reasonable size**, without exceeding the +**500 LOC**; more significant changes must be split on more separate Pull Requests or part of a feature branch. + +The **branch name** must include the **ticket(s) number** whenever possible, with the project prefix if not `MAILAND`, followed by a brief description of the change: e.g., `fix/123_crash-on-login` or `chore/L10N-234_capilalize-cancel-button` + +The **commit message** must be imperative and include a brief description if needed. The last line must include the whole ticket: e.g. + +``` +Fix crash on login + +A network request was being launched on the Main thread, +now it has been moved inside a coroutine + +MAILAND-123 +``` + +### Architecture + +Every change must follow the target **architecture of the project**. + +### Tests + +We expect any change to be accompanied with an **appropriate suite of automated tests**. + +### Code style + +**Code style** must be compliant with **Kotlin/Android conventions** and follow the **internal rules** agreed by the team. + +**Installing the Detekt plugin is a must** as it helps to follow the defined rules naturally. + +Merge/Pull Requests that introduce new Detekt issues won't be accepted. diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000000..45dd994d15 --- /dev/null +++ b/Dangerfile @@ -0,0 +1,2 @@ +warn("The MR exceeds the current size limit of 500 added lines. Please consider breaking it down.") if git.insertions > 500 + diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..2021f0304f --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "danger-gitlab", "~> 8.0" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000..912fa7cd3e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,285 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.1001.0) + aws-sdk-core (3.211.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + bigdecimal (3.1.8) + claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + cork (0.3.0) + colored2 (~> 3.1) + csv (3.3.0) + danger (9.5.1) + base64 (~> 0.2) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 3.0) + faraday-http-cache (~> 2.0) + git (~> 1.13) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + octokit (>= 4.0) + pstore (~> 0.1) + terminal-table (>= 1, < 4) + danger-gitlab (8.0.0) + danger + gitlab (~> 4.2, >= 4.2.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-http-cache (2.5.1) + faraday (>= 0.8) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.225.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-firebase_app_distribution (0.9.1) + google-apis-firebaseappdistribution_v1 (~> 0.3.0) + google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + git (1.19.1) + addressable (~> 2.8) + rchardet (~> 1.8) + gitlab (4.20.1) + httparty (~> 0.20) + terminal-table (>= 1.5.1) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-firebaseappdistribution_v1 (0.3.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-firebaseappdistribution_v1alpha (0.2.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.7) + domain_name (~> 0.5) + httparty (0.22.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.7.6) + jwt (2.9.3) + base64 + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + multipart-post (2.4.1) + nanaimo (0.4.0) + nap (1.1.0) + naturally (2.2.1) + nkf (0.2.0) + octokit (9.2.0) + faraday (>= 1, < 3) + sawyer (~> 0.9) + open4 (1.3.4) + optparse (0.5.0) + os (1.1.4) + plist (3.7.1) + pstore (0.1.3) + public_suffix (6.0.1) + rake (13.2.1) + rchardet (1.8.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.3.9) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-22 + x86_64-darwin-20 + x86_64-linux + +DEPENDENCIES + danger-gitlab (~> 8.0) + fastlane + fastlane-plugin-firebase_app_distribution + +BUNDLED WITH + 2.2.30 diff --git a/README.md b/README.md index 998c5cdaff..1c147899a7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,67 @@ -ProtonMail for Android +Proton Mail for Android ======================= -Copyright (c) 2021 Proton Technologies AG +Copyright (c) 2024 Proton Technologies AG +## Contributing +We’ve recently rebuilt the app and are focused on our current roadmap of features and fixes. Therefore, we are not accepting new issues or PRs at the moment: feel free to fork the repo for your own experiments. We appreciate your understanding and support. + +## Build instructions +- Install and configure the environment (two options available) + - [Android Studio bundle](https://developer.android.com/studio/install) + - [Standalone Android tools](https://developer.android.com/tools) +- Install and configure Java 17+ (not needed for Android Studio bundle as it's included) + - Install Java 17 with `brew install openjdk@17` | `apt install openjdk-17-jdk` + - Set Java 17 as the current version by using the `JAVA_HOME` environment variable +- Clone this repository (Use `git clone git@github.com:ProtonMail/android-mail.git`.) +- Setup `google-services.json` file by running `./scripts/setup_google_services.sh` +- Build with any of the following: + - Execute `./gradlew assembleAlphaDebug` in a terminal + - Open Android Studio and build the `:app` module + +## CI / CD +CI stages are defined in the `.gitlab-ci.yml` file and we rely on [fastlane](https://docs.fastlane.tools/) to implement most of them. +Fastlane can be installed and used locally by performing +``` +bundle install +``` +(requires Ruby and `bundler` to be available locally) +``` +bundle exec fastlane lanes +``` +will show all the possible actions that are available. + +## UI Tests +UI tests are executed on Firebase Test Lab through the CI. UI tests must run on a `dev` flavour (`devDebug` for instance). + +While instrumented tests can be run locally with no additional setup, in order to run the tests located in the `app/src/uiTest` folder, some assets (`users.json` and `internal_api.json` for instance) might need to be downloaded and configured. + +## Deploy +Each merge to `main` branch builds the branch's HEAD and deploys it +to [Firebase App Distribution](https://firebase.google.com/docs/app-distribution). + +## Signing +All `release` builds done on CI are automatically signed with ProtonMail's keystore. In order to perform signing locally, the keystore will need to be placed into the `keystore/` directory and the credentials will be read from `private.properties` file. + +## Observability +Crashes and errors that happen in `release` (non debuggable) builds are reported to Sentry in an anonymized form. +The CI sets up the integration with Sentry by providing in the build environment `private.properties` and `sentry.properties` files that contain the secrets needed. +This can as well be performed locally by creating `private.properties` and `sentry.properties` files and filling them with the needed secrets (eg. `SentryDSN`; for more details about the `sentry.properties` file, see https://docs.sentry.io/platforms/android/gradle/#proguardr8--dexguard). + +## Use core libraries from local git submodule +It is possible to run the application getting the "core" libraries from the local git submodule instead of gradle by setting the following flag to true in `gradle.properties` file: + +``` +useCoreGitSubmodule=true +``` + +## Code style +This project's code style and formatting is checked by detekt. The rule set is [ktlint's default one](https://github.com/pinterest/ktlint) + + +## Troubleshooting +- `goopenpgp.aar` library not found: submodule not properly setup, please follow steps in build instructions License ------- -The code and datafiles in this distribution are licensed under the terms of the GPLv3 as published by the Free Software Foundation. See https://www.gnu.org/licenses/ for a copy of this license. +The code and data files in this distribution are licensed under the terms of the GPLv3 as published by the Free Software Foundation. See https://www.gnu.org/licenses/ for a copy of this license. diff --git a/app/.gitignore b/app/.gitignore index 42afabfd2a..1b1498e855 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,6 @@ -/build \ No newline at end of file +/build + +google-services.json + +# UI Tests Assets folder +src/uiTest/assets/network-mocks diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index c7968d1bc4..0000000000 --- a/app/build.gradle +++ /dev/null @@ -1,43 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdk 30 - - defaultConfig { - applicationId "ch.protonmail.android" - minSdk 21 - targetSdk 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } -} - -dependencies { - - implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' - testImplementation 'junit:junit:4.+' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000000..d9eb4e74b4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +import java.util.Properties +import com.android.build.api.dsl.VariantDimension +import configuration.extensions.protonEnvironment +import kotlin.collections.forEach +import kotlin.collections.listOf +import kotlin.collections.mapOf +import kotlin.collections.plusAssign +import kotlin.collections.set + +plugins { + id("com.android.application") + id("com.google.gms.google-services") + kotlin("android") + kotlin("kapt") + id("com.google.devtools.ksp") + id("dagger.hilt.android.plugin") + id("io.sentry.android.gradle") + id("org.jetbrains.kotlinx.kover") + id("me.proton.core.gradle-plugins.environment-config") version libs.versions.proton.core.plugin.get() + id("org.jetbrains.kotlin.plugin.compose") +} + +val privateProperties = Properties().apply { + @Suppress("SwallowedException") + try { + load(rootDir.resolve("private.properties").inputStream()) + } catch (exception: java.io.FileNotFoundException) { + // Provide empty properties to allow the app to be built without secrets + Properties() + } +} + +val accountSentryDSN: String = privateProperties.getProperty("accountSentryDSN") ?: "" +val sentryDSN: String = privateProperties.getProperty("sentryDSN") ?: "" +val proxyToken: String? = privateProperties.getProperty("PROXY_TOKEN") + +android { + namespace = "ch.protonmail.android" + compileSdk = Config.compileSdk + + defaultConfig { + applicationId = Config.applicationId + minSdk = Config.minSdk + targetSdk = Config.targetSdk + versionCode = Config.versionCode + versionName = Config.versionName + testInstrumentationRunner = Config.testInstrumentationRunner + testInstrumentationRunnerArguments["clearPackageData"] = "true" + + javaCompileOptions { + annotationProcessorOptions { + arguments["room.schemaLocation"] = "$projectDir/schemas" + arguments["room.incremental"] = "true" + } + } + + protonEnvironment { + apiPrefix = "mail-api" + } + + buildConfigField("String", "SENTRY_DSN", sentryDSN.toBuildConfigValue()) + buildConfigField("String", "ACCOUNT_SENTRY_DSN", accountSentryDSN.toBuildConfigValue()) + buildConfigField("String", "PROXY_TOKEN", proxyToken.toBuildConfigValue()) + + setAssetLinksResValue("proton.me") + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + signingConfigs { + getByName("debug") { + val debugKeystore = file("$rootDir/keystore/debug.keystore") + + // Use the shared keystore if present (either CI or internal usage). + if (debugKeystore.exists()) { + storeFile = file("$rootDir/keystore/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + register("release") { + storeFile = file("$rootDir/keystore/ProtonMail.keystore") + storePassword = "${privateProperties["keyStorePassword"]}" + keyAlias = "ProtonMail" + keyPassword = "${privateProperties["keyStoreKeyPassword"]}" + } + } + + buildTypes { + // In UI Tests, we don't want the FCM service to be instantiated. + val isFcmServiceEnabled = properties["enableFcmService"] ?: true + + debug { + isDebuggable = true + enableUnitTestCoverage = false + postprocessing { + isObfuscate = false + isOptimizeCode = false + isRemoveUnusedCode = false + isRemoveUnusedResources = false + } + manifestPlaceholders["isFcmServiceEnabled"] = isFcmServiceEnabled + } + release { + isDebuggable = false + enableUnitTestCoverage = false + postprocessing { + isObfuscate = false + isOptimizeCode = true + isRemoveUnusedCode = true + isRemoveUnusedResources = true + file("proguard").listFiles()?.forEach { proguardFile(it) } + } + manifestPlaceholders["isFcmServiceEnabled"] = isFcmServiceEnabled + signingConfig = signingConfigs["release"] + } + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + postprocessing { + isObfuscate = false + } + manifestPlaceholders["isFcmServiceEnabled"] = false + defaultConfig { + testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true" + } + } + } + + flavorDimensions.add("default") + productFlavors { + val gitHash = "git rev-parse --short HEAD".runCommand(workingDir = rootDir) + create("dev") { + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev+$gitHash" + buildConfigField("Boolean", "USE_DEFAULT_PINS", "false") + + val protonHost = "proton.black" + protonEnvironment { + host = protonHost + apiPrefix = "mail-api" + } + setAssetLinksResValue(protonHost) + } + create("alpha") { + applicationIdSuffix = ".alpha" + versionNameSuffix = "-alpha+$gitHash" + buildConfigField("Boolean", "USE_DEFAULT_PINS", "true") + } + create("prod") { + buildConfigField("Boolean", "USE_DEFAULT_PINS", "true") + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + compose = true + buildConfig = true + } + + hilt { + enableAggregatingTask = true + } + + packaging { + resources.excludes.add("MANIFEST.MF") + resources.excludes.add("META-INF/LICENSE*") + resources.excludes.add("META-INF/licenses/**") + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + resources.excludes.add("META-INF/gradle/incremental.annotation.processors") + } + + sourceSets { + getByName("main").java.srcDirs("src/main/kotlin") + getByName("test").java.srcDirs("src/test/kotlin") + getByName("androidTest").java.srcDirs("src/androidTest/kotlin", "src/uiTest/kotlin") + getByName("androidTest").assets.srcDirs("src/uiTest/assets", "schemas") + getByName("androidTest").res.srcDirs("src/uiTest/res") + getByName("dev").res.srcDirs("src/dev/res") + getByName("alpha").res.srcDirs("src/alpha/res") + } +} + +configurations { + // Remove duplicate classes (keep "org.jetbrains"). + implementation.get().exclude(mapOf("group" to "com.intellij", "module" to "annotations")) + implementation.get().exclude(mapOf("group" to "org.intellij", "module" to "annotations")) +} + +sentry { + autoInstallation { + sentryVersion.set(libs.versions.sentry.asProvider()) + } +} + +dependencies { + implementation(files("../../proton-libs/gopenpgp/gopenpgp.aar")) + + implementation(libs.bundles.appLibs) + implementation(libs.proton.core.proguardRules) + + implementation(project(":mail-bugreport")) + implementation(project(":mail-common")) + implementation(project(":mail-composer")) + implementation(project(":mail-contact")) + implementation(project(":mail-conversation")) + implementation(project(":mail-detail")) + implementation(project(":mail-label")) + implementation(project(":mail-mailbox")) + implementation(project(":mail-message")) + implementation(project(":mail-notifications")) + implementation(project(":mail-pagination")) + implementation(project(":mail-settings")) + implementation(project(":mail-upselling")) + implementation(project(":mail-onboarding")) + implementation(project(":mail-sidebar")) + implementation(project(":uicomponents")) + + val devImplementation by configurations + devImplementation(libs.bundles.app.debug) + + // Environment configuration + releaseImplementation(libs.proton.core.configuration.dagger.static) + debugImplementation(libs.proton.core.configuration.dagger.contentProvider) + + kapt(libs.bundles.app.annotationProcessors) + + coreLibraryDesugaring(libs.android.tools.desugarJdkLibs) + + // To see the traces as results we need to include Perfetto. + // We should not include in the production as it increases the APK size. + val benchmarkImplementation by configurations + benchmarkImplementation(libs.androidx.tracing) + benchmarkImplementation(libs.androidx.tracing.compose.runtime) + benchmarkImplementation(libs.androidx.tracing.perfetto) + benchmarkImplementation(libs.androidx.tracing.perfetto.binary) + // Also include configDaggerStatic to provide the required Hilt bindings in benchmark. + benchmarkImplementation(libs.proton.core.configuration.dagger.static) + + testImplementation(libs.bundles.test) + testImplementation(project(":test:test-data")) + testImplementation(project(":test:utils")) + + androidTestImplementation(libs.bundles.test.androidTest) + androidTestImplementation(libs.proton.core.accountManagerPresentationCompose) + androidTestImplementation(libs.proton.core.accountRecoveryTest) + androidTestImplementation(libs.proton.core.authTest) + androidTestImplementation(libs.proton.core.planTest) + androidTestImplementation(libs.proton.core.reportTest) + androidTestImplementation(libs.proton.core.userRecoveryTest) + androidTestImplementation(libs.proton.core.testRule) + androidTestImplementation(project(":test:annotations")) + androidTestImplementation(project(":test:idlingresources")) + androidTestImplementation(project(":test:robot:core")) + androidTestImplementation(project(":test:robot:ksp:annotations")) + androidTestImplementation(project(":test:test-data")) + androidTestImplementation(project(":test:network-mocks")) + androidTestImplementation(project(":test:utils")) + androidTestImplementation(project(":uicomponents")) // Needed for shared test tags. + + androidTestUtil(libs.androidx.test.orchestrator) + kaptAndroidTest(libs.dagger.hilt.compiler) + kspAndroidTest(project(":test:robot:ksp:processor")) +} + +fun String?.toBuildConfigValue() = if (this != null) "\"$this\"" else "null" + +fun VariantDimension.setAssetLinksResValue(host: String) { + resValue( + type = "string", name = "asset_statements", + value = """ + [{ + "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"], + "target": { "namespace": "web", "site": "https://$host" } + }] + """.trimIndent() + ) +} diff --git a/app/proguard/autoservice.pro b/app/proguard/autoservice.pro new file mode 100644 index 0000000000..80c1ec9d53 --- /dev/null +++ b/app/proguard/autoservice.pro @@ -0,0 +1 @@ +-dontwarn com.google.auto.service.AutoService diff --git a/app/proguard/ezvcard.pro b/app/proguard/ezvcard.pro new file mode 100644 index 0000000000..94f2b83a7f --- /dev/null +++ b/app/proguard/ezvcard.pro @@ -0,0 +1,3 @@ +-keep,includedescriptorclasses class ezvcard.** { *; } +-dontwarn ezvcard.io.json.** +-dontwarn freemarker.** diff --git a/app/proguard/firebase.pro b/app/proguard/firebase.pro new file mode 100644 index 0000000000..67ca987594 --- /dev/null +++ b/app/proguard/firebase.pro @@ -0,0 +1 @@ +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector diff --git a/app/proguard/javax.pro b/app/proguard/javax.pro new file mode 100644 index 0000000000..a1e22d1de5 --- /dev/null +++ b/app/proguard/javax.pro @@ -0,0 +1,35 @@ +-dontwarn javax.lang.model.element.Element +-dontwarn javax.lang.model.element.ElementKind +-dontwarn javax.lang.model.element.ElementVisitor +-dontwarn javax.lang.model.element.ExecutableElement +-dontwarn javax.lang.model.element.Modifier +-dontwarn javax.lang.model.element.Name +-dontwarn javax.lang.model.element.PackageElement +-dontwarn javax.lang.model.element.TypeElement +-dontwarn javax.lang.model.element.TypeParameterElement +-dontwarn javax.lang.model.element.VariableElement +-dontwarn javax.lang.model.type.ArrayType +-dontwarn javax.lang.model.type.DeclaredType +-dontwarn javax.lang.model.type.ExecutableType +-dontwarn javax.lang.model.type.TypeKind +-dontwarn javax.lang.model.type.TypeMirror +-dontwarn javax.lang.model.type.TypeVariable +-dontwarn javax.lang.model.type.TypeVisitor +-dontwarn javax.lang.model.util.ElementFilter +-dontwarn javax.lang.model.util.SimpleElementVisitor8 +-dontwarn javax.lang.model.util.SimpleTypeVisitor8 +-dontwarn javax.lang.model.util.Types +-dontwarn javax.lang.model.SourceVersion +-dontwarn javax.naming.NamingEnumeration +-dontwarn javax.naming.NamingException +-dontwarn javax.naming.directory.Attribute +-dontwarn javax.naming.directory.Attributes +-dontwarn javax.naming.directory.DirContext +-dontwarn javax.naming.directory.InitialDirContext +-dontwarn javax.lang.model.element.AnnotationMirror +-dontwarn javax.lang.model.element.AnnotationValue +-dontwarn javax.lang.model.util.AbstractAnnotationValueVisitor8 +-dontwarn javax.lang.model.util.AbstractTypeVisitor8 +-dontwarn javax.lang.model.util.Elements +-dontwarn javax.lang.model.util.SimpleTypeVisitor7 +-dontwarn javax.tools.Diagnostic$Kind diff --git a/app/proguard/okhttp3.pro b/app/proguard/okhttp3.pro new file mode 100644 index 0000000000..a6477597d5 --- /dev/null +++ b/app/proguard/okhttp3.pro @@ -0,0 +1,7 @@ +# Source: https://square.github.io/okhttp/features/r8_proguard/ + +# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** \ No newline at end of file diff --git a/app/proguard/room.pro b/app/proguard/room.pro new file mode 100644 index 0000000000..dcb71edb84 --- /dev/null +++ b/app/proguard/room.pro @@ -0,0 +1,2 @@ +# Brought by Room 2.7.0 +-dontwarn com.google.j2objc.annotations.RetainedWith diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/1.json b/app/schemas/ch.protonmail.android.db.AppDatabase/1.json new file mode 100644 index 0000000000..ec84239d86 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/1.json @@ -0,0 +1,3146 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "0bd024eadf4dce202657b085b3b22b9d", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vpnPlanName", + "columnName": "vpnPlanName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0bd024eadf4dce202657b085b3b22b9d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/10.json b/app/schemas/ch.protonmail.android.db.AppDatabase/10.json new file mode 100644 index 0000000000..b325ed0be7 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/10.json @@ -0,0 +1,3682 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "a122a210951f91b2cd03ce7bcedee693", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a122a210951f91b2cd03ce7bcedee693')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/11.json b/app/schemas/ch.protonmail.android.db.AppDatabase/11.json new file mode 100644 index 0000000000..06dcd5ba00 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/11.json @@ -0,0 +1,3772 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "822827425ba0e30bb7a407f57d96fb45", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '822827425ba0e30bb7a407f57d96fb45')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/12.json b/app/schemas/ch.protonmail.android.db.AppDatabase/12.json new file mode 100644 index 0000000000..898ded1e53 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/12.json @@ -0,0 +1,3772 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "22f26237429ade517469164a64e5ff00", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22f26237429ade517469164a64e5ff00')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/13.json b/app/schemas/ch.protonmail.android.db.AppDatabase/13.json new file mode 100644 index 0000000000..acfdf56efb --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/13.json @@ -0,0 +1,3772 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "d06e014ff5757b117a638d2ccd4c9c18", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "NO ACTION", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd06e014ff5757b117a638d2ccd4c9c18')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/14.json b/app/schemas/ch.protonmail.android.db.AppDatabase/14.json new file mode 100644 index 0000000000..8f9ead54d1 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/14.json @@ -0,0 +1,3778 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "d7430c695f1d1162d145a24aa1935b73", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "NO ACTION", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd7430c695f1d1162d145a24aa1935b73')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/15.json b/app/schemas/ch.protonmail.android.db.AppDatabase/15.json new file mode 100644 index 0000000000..a36ff07bf3 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/15.json @@ -0,0 +1,3862 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "02be95fa20752aefe4e570984458d219", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "NO ACTION", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02be95fa20752aefe4e570984458d219')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/16.json b/app/schemas/ch.protonmail.android.db.AppDatabase/16.json new file mode 100644 index 0000000000..886b3d7578 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/16.json @@ -0,0 +1,3952 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "ab0cc7f9cb17a1aa115b859045d2bdb6", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "NO ACTION", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `apiAttachmentId` TEXT, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiAttachmentId", + "columnName": "apiAttachmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab0cc7f9cb17a1aa115b859045d2bdb6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/17.json b/app/schemas/ch.protonmail.android.db.AppDatabase/17.json new file mode 100644 index 0000000000..5944650a9a --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/17.json @@ -0,0 +1,3973 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "2842f022e15db5ce458ef138b12e439c", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "NO ACTION", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2842f022e15db5ce458ef138b12e439c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/18.json b/app/schemas/ch.protonmail.android.db.AppDatabase/18.json new file mode 100644 index 0000000000..bf36675757 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/18.json @@ -0,0 +1,3973 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "3b7688ffd6d2cd2fb003dba84e1cb596", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b7688ffd6d2cd2fb003dba84e1cb596')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/19.json b/app/schemas/ch.protonmail.android.db.AppDatabase/19.json new file mode 100644 index 0000000000..f5f4905292 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/19.json @@ -0,0 +1,3973 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "3b7688ffd6d2cd2fb003dba84e1cb596", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b7688ffd6d2cd2fb003dba84e1cb596')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/2.json b/app/schemas/ch.protonmail.android.db.AppDatabase/2.json new file mode 100644 index 0000000000..6f185d09aa --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/2.json @@ -0,0 +1,3291 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "e505d6e29be67cefc1439a957860816e", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `vpnPlanName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "vpnPlanName", + "columnName": "vpnPlanName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e505d6e29be67cefc1439a957860816e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/20.json b/app/schemas/ch.protonmail.android.db.AppDatabase/20.json new file mode 100644 index 0000000000..989c486f58 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/20.json @@ -0,0 +1,3979 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "b3f5d9a75411fdcbc4073700ef925ccc", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b3f5d9a75411fdcbc4073700ef925ccc')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/21.json b/app/schemas/ch.protonmail.android.db.AppDatabase/21.json new file mode 100644 index 0000000000..c1b9497c0c --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/21.json @@ -0,0 +1,3985 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "8b5df661a3631485193ebc199e2f1e9b", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b5df661a3631485193ebc199e2f1e9b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/23.json b/app/schemas/ch.protonmail.android.db.AppDatabase/23.json new file mode 100644 index 0000000000..c3ad6dc3fa --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/23.json @@ -0,0 +1,4131 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "fb8ba7ba7d30f7f729d93525d98f5c54", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb8ba7ba7d30f7f729d93525d98f5c54')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/24.json b/app/schemas/ch.protonmail.android.db.AppDatabase/24.json new file mode 100644 index 0000000000..fada3c0654 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/24.json @@ -0,0 +1,4131 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "fb8ba7ba7d30f7f729d93525d98f5c54", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb8ba7ba7d30f7f729d93525d98f5c54')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/25.json b/app/schemas/ch.protonmail.android.db.AppDatabase/25.json new file mode 100644 index 0000000000..e80fe76cd1 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/25.json @@ -0,0 +1,4228 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "85db82d0d31b0983bef9d9ed1b4083b0", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '85db82d0d31b0983bef9d9ed1b4083b0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/26.json b/app/schemas/ch.protonmail.android.db.AppDatabase/26.json new file mode 100644 index 0000000000..4873149c6f --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/26.json @@ -0,0 +1,4299 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "f77545b762e5eda425276b9209969e5f", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f77545b762e5eda425276b9209969e5f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/27.json b/app/schemas/ch.protonmail.android.db.AppDatabase/27.json new file mode 100644 index 0000000000..43519fb6d4 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/27.json @@ -0,0 +1,4312 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "eef1dcc135deea7dc0f94311a298e1c1", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eef1dcc135deea7dc0f94311a298e1c1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/28.json b/app/schemas/ch.protonmail.android.db.AppDatabase/28.json new file mode 100644 index 0000000000..b97665a178 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/28.json @@ -0,0 +1,4354 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "65432648968a0d64842daca84107991c", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65432648968a0d64842daca84107991c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/29.json b/app/schemas/ch.protonmail.android.db.AppDatabase/29.json new file mode 100644 index 0000000000..fe4f431ea2 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/29.json @@ -0,0 +1,4432 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "9f52450def70d8a04e1e1cb8869e513d", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f52450def70d8a04e1e1cb8869e513d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/3.json b/app/schemas/ch.protonmail.android.db.AppDatabase/3.json new file mode 100644 index 0000000000..f2f0a654dd --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/3.json @@ -0,0 +1,3285 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "129ca82a3de645b7a68c46115d23cb25", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '129ca82a3de645b7a68c46115d23cb25')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/30.json b/app/schemas/ch.protonmail.android.db.AppDatabase/30.json new file mode 100644 index 0000000000..7b504b5c94 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/30.json @@ -0,0 +1,4412 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "7c96f3af23d6f7feda6aa1bd729915ca", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c96f3af23d6f7feda6aa1bd729915ca')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/31.json b/app/schemas/ch.protonmail.android.db.AppDatabase/31.json new file mode 100644 index 0000000000..25f19c33ea --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/31.json @@ -0,0 +1,4418 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "63a64aa7e51832bbb126ed7c46d229b9", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '63a64aa7e51832bbb126ed7c46d229b9')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/32.json b/app/schemas/ch.protonmail.android.db.AppDatabase/32.json new file mode 100644 index 0000000000..d55b33e47b --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/32.json @@ -0,0 +1,4541 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "bfeffcd525cd23485cb14b962d0eedcb", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bfeffcd525cd23485cb14b962d0eedcb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/33.json b/app/schemas/ch.protonmail.android.db.AppDatabase/33.json new file mode 100644 index 0000000000..7d9f1ff0f4 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/33.json @@ -0,0 +1,4547 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "156afa2112a39a051b14e08cda0cfe38", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecret` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecret", + "columnName": "recoverySecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '156afa2112a39a051b14e08cda0cfe38')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/34.json b/app/schemas/ch.protonmail.android.db.AppDatabase/34.json new file mode 100644 index 0000000000..b5603d46a6 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/34.json @@ -0,0 +1,4613 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "ed894686eabf89734c044add8d764fa7", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed894686eabf89734c044add8d764fa7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/35.json b/app/schemas/ch.protonmail.android.db.AppDatabase/35.json new file mode 100644 index 0000000000..d694fa29b2 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/35.json @@ -0,0 +1,4812 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "206d6e3f49e053d03ded9c9e6303e062", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '206d6e3f49e053d03ded9c9e6303e062')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/36.json b/app/schemas/ch.protonmail.android.db.AppDatabase/36.json new file mode 100644 index 0000000000..fda7dfdcd8 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/36.json @@ -0,0 +1,4818 @@ +{ + "formatVersion": 1, + "database": { + "version": 36, + "identityHash": "d1fd18583a9abc7ad0ec13694bdf164a", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd1fd18583a9abc7ad0ec13694bdf164a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/37.json b/app/schemas/ch.protonmail.android.db.AppDatabase/37.json new file mode 100644 index 0000000000..53355d57b9 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/37.json @@ -0,0 +1,4984 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "3bb1abb2f8f60e4f1b0411e997fe3fda", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3bb1abb2f8f60e4f1b0411e997fe3fda')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/38.json b/app/schemas/ch.protonmail.android.db.AppDatabase/38.json new file mode 100644 index 0000000000..2ed1e5ff1c --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/38.json @@ -0,0 +1,5141 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "e3fa38cf0f90524e7944582a6b1e3969", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e3fa38cf0f90524e7944582a6b1e3969')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/39.json b/app/schemas/ch.protonmail.android.db.AppDatabase/39.json new file mode 100644 index 0000000000..1734104704 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/39.json @@ -0,0 +1,4973 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "3141f216ed50b50a7a3224ca0291f13f", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER" + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT" + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT" + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT" + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT" + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + } + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `almostAllMail` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, `mobileSettings_listToolbar_isCustom` INTEGER, `mobileSettings_listToolbar_actions` TEXT, `mobileSettings_messageToolbar_isCustom` INTEGER, `mobileSettings_messageToolbar_actions` TEXT, `mobileSettings_conversationToolbar_isCustom` INTEGER, `mobileSettings_conversationToolbar_actions` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER" + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER" + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER" + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER" + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER" + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER" + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "almostAllMail", + "columnName": "almostAllMail", + "affinity": "INTEGER" + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER" + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER" + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER" + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER" + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER" + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.isCustom", + "columnName": "mobileSettings_listToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.actions", + "columnName": "mobileSettings_listToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.isCustom", + "columnName": "mobileSettings_messageToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.actions", + "columnName": "mobileSettings_messageToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.isCustom", + "columnName": "mobileSettings_conversationToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.actions", + "columnName": "mobileSettings_conversationToolbar_actions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER" + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT" + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER" + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER" + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER" + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER" + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT" + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER" + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER" + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT" + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER" + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER" + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + } + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT" + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT" + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT" + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT" + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3141f216ed50b50a7a3224ca0291f13f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/4.json b/app/schemas/ch.protonmail.android.db.AppDatabase/4.json new file mode 100644 index 0000000000..6e37396b22 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/4.json @@ -0,0 +1,3440 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "bf1cb014f12f87a96e72b057828cd43c", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf1cb014f12f87a96e72b057828cd43c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/40.json b/app/schemas/ch.protonmail.android.db.AppDatabase/40.json new file mode 100644 index 0000000000..2378db1348 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/40.json @@ -0,0 +1,4978 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "96bf33111556c9e356410ae666ae19ba", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER" + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT" + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT" + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT" + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT" + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + } + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `almostAllMail` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, `mobileSettings_listToolbar_isCustom` INTEGER, `mobileSettings_listToolbar_actions` TEXT, `mobileSettings_messageToolbar_isCustom` INTEGER, `mobileSettings_messageToolbar_actions` TEXT, `mobileSettings_conversationToolbar_isCustom` INTEGER, `mobileSettings_conversationToolbar_actions` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER" + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER" + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER" + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER" + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER" + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER" + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "almostAllMail", + "columnName": "almostAllMail", + "affinity": "INTEGER" + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER" + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER" + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER" + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER" + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER" + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.isCustom", + "columnName": "mobileSettings_listToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.actions", + "columnName": "mobileSettings_listToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.isCustom", + "columnName": "mobileSettings_messageToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.actions", + "columnName": "mobileSettings_messageToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.isCustom", + "columnName": "mobileSettings_conversationToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.actions", + "columnName": "mobileSettings_conversationToolbar_actions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `easyDeviceMigrationOptOut` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER" + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT" + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER" + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER" + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER" + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER" + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "easyDeviceMigrationOptOut", + "columnName": "easyDeviceMigrationOptOut", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT" + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER" + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER" + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT" + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER" + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER" + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + } + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT" + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT" + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT" + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT" + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '96bf33111556c9e356410ae666ae19ba')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/41.json b/app/schemas/ch.protonmail.android.db.AppDatabase/41.json new file mode 100644 index 0000000000..1de6d706dc --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/41.json @@ -0,0 +1,4982 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "d3223ec0c60d605b6eda03e93c3cda5a", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "product" + ] + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `passphrase` BLOB, `password` TEXT, `fido2AuthenticationOptionsJson` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "fido2AuthenticationOptionsJson", + "columnName": "fido2AuthenticationOptionsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AuthDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_AuthDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AuthDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AuthDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DeviceSecretEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `secret` TEXT NOT NULL, `token` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_DeviceSecretEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DeviceSecretEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MemberDeviceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `memberId` TEXT NOT NULL, `addressId` TEXT, `state` INTEGER NOT NULL, `name` TEXT NOT NULL, `localizedClientName` TEXT NOT NULL, `platform` TEXT, `createdAtUtcSeconds` INTEGER NOT NULL, `activatedAtUtcSeconds` INTEGER, `rejectedAtUtcSeconds` INTEGER, `activationToken` TEXT, `lastActivityAtUtcSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `deviceId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localizedClientName", + "columnName": "localizedClientName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtUtcSeconds", + "columnName": "createdAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activatedAtUtcSeconds", + "columnName": "activatedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAtUtcSeconds", + "columnName": "rejectedAtUtcSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "activationToken", + "columnName": "activationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityAtUtcSeconds", + "columnName": "lastActivityAtUtcSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "deviceId" + ] + }, + "indices": [ + { + "name": "index_MemberDeviceEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MemberDeviceEntity_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_memberId` ON `${TABLE_NAME}` (`memberId`)" + }, + { + "name": "index_MemberDeviceEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MemberDeviceEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `createdAtUtc` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `type` INTEGER, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `flags` TEXT, `maxBaseSpace` INTEGER, `maxDriveSpace` INTEGER, `usedBaseSpace` INTEGER, `usedDriveSpace` INTEGER, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtUtc", + "columnName": "createdAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER" + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "TEXT" + }, + { + "fieldPath": "maxBaseSpace", + "columnName": "maxBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDriveSpace", + "columnName": "maxDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedBaseSpace", + "columnName": "usedBaseSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDriveSpace", + "columnName": "usedDriveSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, `recoverySecretHash` TEXT, `recoverySecretSignature` TEXT, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT" + }, + { + "fieldPath": "recoverySecretSignature", + "columnName": "recoverySecretSignature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT" + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "addressId" + ] + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT" + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyId" + ] + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "RecoveryFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `createdAtUtcMillis` INTEGER NOT NULL, `keyCount` INTEGER, `recoveryFile` TEXT NOT NULL, `recoverySecretHash` TEXT NOT NULL, PRIMARY KEY(`recoverySecretHash`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtUtcMillis", + "columnName": "createdAtUtcMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyCount", + "columnName": "keyCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "recoveryFile", + "columnName": "recoveryFile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recoverySecretHash", + "columnName": "recoverySecretHash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recoverySecretHash" + ] + }, + "indices": [ + { + "name": "index_RecoveryFileEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RecoveryFileEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "keyId" + ] + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "PublicAddressInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `warnings` TEXT NOT NULL, `protonMx` INTEGER NOT NULL, `isProton` INTEGER NOT NULL, `addressSignedKeyList_data` TEXT, `addressSignedKeyList_signature` TEXT, `addressSignedKeyList_minEpochId` INTEGER, `addressSignedKeyList_maxEpochId` INTEGER, `addressSignedKeyList_expectedMinEpochId` INTEGER, `catchAllSignedKeyList_data` TEXT, `catchAllSignedKeyList_signature` TEXT, `catchAllSignedKeyList_minEpochId` INTEGER, `catchAllSignedKeyList_maxEpochId` INTEGER, `catchAllSignedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "warnings", + "columnName": "warnings", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protonMx", + "columnName": "protonMx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressSignedKeyList.data", + "columnName": "addressSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.signature", + "columnName": "addressSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "addressSignedKeyList.minEpochId", + "columnName": "addressSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.maxEpochId", + "columnName": "addressSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "addressSignedKeyList.expectedMinEpochId", + "columnName": "addressSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.data", + "columnName": "catchAllSignedKeyList_data", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.signature", + "columnName": "catchAllSignedKeyList_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "catchAllSignedKeyList.minEpochId", + "columnName": "catchAllSignedKeyList_minEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.maxEpochId", + "columnName": "catchAllSignedKeyList_maxEpochId", + "affinity": "INTEGER" + }, + { + "fieldPath": "catchAllSignedKeyList.expectedMinEpochId", + "columnName": "catchAllSignedKeyList_expectedMinEpochId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email" + ] + }, + "indices": [ + { + "name": "index_PublicAddressInfoEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressInfoEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ] + }, + { + "tableName": "PublicAddressKeyDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `emailAddressType` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `source` INTEGER, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressInfoEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailAddressType", + "columnName": "emailAddressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "email", + "publicKey" + ] + }, + "indices": [ + { + "name": "index_PublicAddressKeyDataEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyDataEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressInfoEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT" + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "clientId" + ] + } + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `autoDeleteSpamAndTrashDays` INTEGER, `almostAllMail` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, `mobileSettings_listToolbar_isCustom` INTEGER, `mobileSettings_listToolbar_actions` TEXT, `mobileSettings_messageToolbar_isCustom` INTEGER, `mobileSettings_messageToolbar_actions` TEXT, `mobileSettings_conversationToolbar_isCustom` INTEGER, `mobileSettings_conversationToolbar_actions` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER" + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER" + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER" + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER" + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER" + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER" + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "autoDeleteSpamAndTrashDays", + "columnName": "autoDeleteSpamAndTrashDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "almostAllMail", + "columnName": "almostAllMail", + "affinity": "INTEGER" + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER" + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER" + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER" + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER" + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER" + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.isCustom", + "columnName": "mobileSettings_listToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.listToolbar.actions", + "columnName": "mobileSettings_listToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.isCustom", + "columnName": "mobileSettings_messageToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.messageToolbar.actions", + "columnName": "mobileSettings_messageToolbar_actions", + "affinity": "TEXT" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.isCustom", + "columnName": "mobileSettings_conversationToolbar_isCustom", + "affinity": "INTEGER" + }, + { + "fieldPath": "mobileSettingsEntity.conversationToolbar.actions", + "columnName": "mobileSettings_conversationToolbar_actions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `deviceRecovery` INTEGER, `telemetry` INTEGER, `crashReports` INTEGER, `sessionAccountRecovery` INTEGER, `easyDeviceMigrationOptOut` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `twoFA_registeredKeys` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER" + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT" + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER" + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER" + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER" + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "deviceRecovery", + "columnName": "deviceRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "telemetry", + "columnName": "telemetry", + "affinity": "INTEGER" + }, + { + "fieldPath": "crashReports", + "columnName": "crashReports", + "affinity": "INTEGER" + }, + { + "fieldPath": "sessionAccountRecovery", + "columnName": "sessionAccountRecovery", + "affinity": "INTEGER" + }, + { + "fieldPath": "easyDeviceMigrationOptOut", + "columnName": "easyDeviceMigrationOptOut", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER" + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER" + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "twoFA.registeredKeys", + "columnName": "twoFA_registeredKeys", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT" + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER" + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER" + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER" + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactId" + ] + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cardId" + ] + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, `isProton` INTEGER, `lastUsedTime` INTEGER NOT NULL, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "isProton", + "columnName": "isProton", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUsedTime", + "columnName": "lastUsedTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId" + ] + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contactEmailId", + "labelId" + ] + }, + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, `fetchedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT" + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER" + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetchedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "config" + ] + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER" + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "featureId" + ] + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "challengeFrame" + ] + } + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "notificationId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "pushId" + ] + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT" + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ] + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT" + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT" + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT" + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `uri` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId" + ] + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "conversationId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "googlePurchaseToken" + ] + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ] + }, + { + "tableName": "PurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `planName` TEXT NOT NULL, `planCycle` INTEGER NOT NULL, `purchaseState` TEXT NOT NULL, `purchaseFailure` TEXT, `paymentProvider` TEXT NOT NULL, `paymentOrderId` TEXT, `paymentToken` TEXT, `paymentCurrency` TEXT NOT NULL, `paymentAmount` INTEGER NOT NULL, PRIMARY KEY(`planName`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planCycle", + "columnName": "planCycle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseState", + "columnName": "purchaseState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purchaseFailure", + "columnName": "purchaseFailure", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentProvider", + "columnName": "paymentProvider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentOrderId", + "columnName": "paymentOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT" + }, + { + "fieldPath": "paymentCurrency", + "columnName": "paymentCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentAmount", + "columnName": "paymentAmount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "planName" + ] + }, + "indices": [ + { + "name": "index_PurchaseEntity_planName", + "unique": false, + "columnNames": [ + "planName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_planName` ON `${TABLE_NAME}` (`planName`)" + }, + { + "name": "index_PurchaseEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_PurchaseEntity_purchaseState", + "unique": false, + "columnNames": [ + "purchaseState" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_purchaseState` ON `${TABLE_NAME}` (`purchaseState`)" + }, + { + "name": "index_PurchaseEntity_paymentProvider", + "unique": false, + "columnNames": [ + "paymentProvider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PurchaseEntity_paymentProvider` ON `${TABLE_NAME}` (`paymentProvider`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TelemetryEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `group` TEXT NOT NULL, `name` TEXT NOT NULL, `values` TEXT NOT NULL, `dimensions` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dimensions", + "columnName": "dimensions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TelemetryEventEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TelemetryEventEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "changeId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "DraftStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, `sendingError` TEXT, `sendingStatusConfirmed` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiMessageId", + "columnName": "apiMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sendingError", + "columnName": "sendingError", + "affinity": "TEXT" + }, + { + "fieldPath": "sendingStatusConfirmed", + "columnName": "sendingStatusConfirmed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_DraftStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_DraftStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AttachmentStateEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ] + }, + "indices": [ + { + "name": "index_AttachmentStateEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + }, + { + "name": "index_AttachmentStateEntity_userId_messageId_attachmentId", + "unique": false, + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `${TABLE_NAME}` (`userId`, `messageId`, `attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + }, + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "MessagePasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "passwordHint", + "columnName": "passwordHint", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessagePasswordEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessagePasswordEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageExpirationTimeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresInSeconds", + "columnName": "expiresInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId" + ] + }, + "indices": [ + { + "name": "index_MessageExpirationTimeEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageExpirationTimeEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "UnreadMessagesCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadMessagesCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadMessagesCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadMessagesCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UnreadConversationsCountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `totalCount` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "labelId" + ] + }, + "indices": [ + { + "name": "index_UnreadConversationsCountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UnreadConversationsCountEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnreadConversationsCountEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SearchResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `keyword`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "messageId", + "keyword" + ] + }, + "indices": [ + { + "name": "index_SearchResultEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_SearchResultEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_SearchResultEntity_keyword", + "unique": false, + "columnNames": [ + "keyword" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_keyword` ON `${TABLE_NAME}` (`keyword`)" + }, + { + "name": "index_SearchResultEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SearchResultEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3223ec0c60d605b6eda03e93c3cda5a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/5.json b/app/schemas/ch.protonmail.android.db.AppDatabase/5.json new file mode 100644 index 0000000000..f94fcee979 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/5.json @@ -0,0 +1,3536 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "5165acdaf36727e0f06cb0cafc3ad641", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `hash` TEXT, `path` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5165acdaf36727e0f06cb0cafc3ad641')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/6.json b/app/schemas/ch.protonmail.android.db.AppDatabase/6.json new file mode 100644 index 0000000000..a42d89e6a7 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/6.json @@ -0,0 +1,3566 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "832abfad28dfd68f008aa2d6d1135205", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `hash` TEXT, `path` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '832abfad28dfd68f008aa2d6d1135205')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/7.json b/app/schemas/ch.protonmail.android.db.AppDatabase/7.json new file mode 100644 index 0000000000..9dbd07553b --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/7.json @@ -0,0 +1,3712 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "5137907c153cc16d1fb655dc8a3d1d90", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `invoiceText` TEXT, `density` INTEGER, `theme` TEXT, `themeType` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `welcome` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, `flags_welcomed` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "invoiceText", + "columnName": "invoiceText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeType", + "columnName": "themeType", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "welcome", + "columnName": "welcome", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags.welcomed", + "columnName": "flags_welcomed", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `hash` TEXT, `path` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5137907c153cc16d1fb655dc8a3d1d90')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/8.json b/app/schemas/ch.protonmail.android.db.AppDatabase/8.json new file mode 100644 index 0000000000..7f32ff0b53 --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/8.json @@ -0,0 +1,3682 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "2e4c68b6967b73c46a5dec686842530e", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `hash` TEXT, `path` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2e4c68b6967b73c46a5dec686842530e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ch.protonmail.android.db.AppDatabase/9.json b/app/schemas/ch.protonmail.android.db.AppDatabase/9.json new file mode 100644 index 0000000000..88df4b634a --- /dev/null +++ b/app/schemas/ch.protonmail.android.db.AppDatabase/9.json @@ -0,0 +1,3688 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "188ba1ddab28818ed047076b010c6b15", + "entities": [ + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `email` TEXT, `state` TEXT NOT NULL, `sessionId` TEXT, `sessionState` TEXT, PRIMARY KEY(`userId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionState", + "columnName": "sessionState", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_AccountEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "AccountMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `product` TEXT NOT NULL, `primaryAtUtc` INTEGER NOT NULL, `migrations` TEXT, PRIMARY KEY(`userId`, `product`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryAtUtc", + "columnName": "primaryAtUtc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "migrations", + "columnName": "migrations", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "product" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AccountMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_AccountMetadataEntity_product", + "unique": false, + "columnNames": [ + "product" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_product` ON `${TABLE_NAME}` (`product`)" + }, + { + "name": "index_AccountMetadataEntity_primaryAtUtc", + "unique": false, + "columnNames": [ + "primaryAtUtc" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AccountMetadataEntity_primaryAtUtc` ON `${TABLE_NAME}` (`primaryAtUtc`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT, `sessionId` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `scopes` TEXT NOT NULL, `product` TEXT NOT NULL, PRIMARY KEY(`sessionId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopes", + "columnName": "scopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "product", + "columnName": "product", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_SessionEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SessionDetailsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `initialEventId` TEXT NOT NULL, `requiredAccountType` TEXT NOT NULL, `secondFactorEnabled` INTEGER NOT NULL, `twoPassModeEnabled` INTEGER NOT NULL, `password` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `SessionEntity`(`sessionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialEventId", + "columnName": "initialEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiredAccountType", + "columnName": "requiredAccountType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondFactorEnabled", + "columnName": "secondFactorEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "twoPassModeEnabled", + "columnName": "twoPassModeEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "sessionId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_SessionDetailsEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SessionDetailsEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "SessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "sessionId" + ] + } + ] + }, + { + "tableName": "UserEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `email` TEXT, `name` TEXT, `displayName` TEXT, `currency` TEXT NOT NULL, `credit` INTEGER NOT NULL, `usedSpace` INTEGER NOT NULL, `maxSpace` INTEGER NOT NULL, `maxUpload` INTEGER NOT NULL, `role` INTEGER, `private` INTEGER NOT NULL, `subscribed` INTEGER NOT NULL, `services` INTEGER NOT NULL, `delinquent` INTEGER, `passphrase` BLOB, `recovery_state` INTEGER, `recovery_startTime` INTEGER, `recovery_endTime` INTEGER, `recovery_sessionId` TEXT, `recovery_reason` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `AccountEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credit", + "columnName": "credit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxUpload", + "columnName": "maxUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPrivate", + "columnName": "private", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscribed", + "columnName": "subscribed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "services", + "columnName": "services", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delinquent", + "columnName": "delinquent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "recovery.state", + "columnName": "recovery_state", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.startTime", + "columnName": "recovery_startTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.endTime", + "columnName": "recovery_endTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recovery.sessionId", + "columnName": "recovery_sessionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recovery.reason", + "columnName": "recovery_reason", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `fingerprint` TEXT, `activation` TEXT, `active` INTEGER, PRIMARY KEY(`keyId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_UserKeyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_UserKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UserKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `addressId` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `domainId` TEXT, `canSend` INTEGER NOT NULL, `canReceive` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `type` INTEGER, `order` INTEGER NOT NULL, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`addressId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "domainId", + "columnName": "domainId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "canSend", + "columnName": "canSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canReceive", + "columnName": "canReceive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signedKeyList.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyList.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyList.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "addressId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "AddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`addressId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `version` INTEGER NOT NULL, `privateKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, `isUnlockable` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `passphrase` BLOB, `token` TEXT, `signature` TEXT, `fingerprint` TEXT, `fingerprints` TEXT, `activation` TEXT, `active` INTEGER NOT NULL, PRIMARY KEY(`keyId`), FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUnlockable", + "columnName": "isUnlockable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fingerprints", + "columnName": "fingerprints", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AddressKeyEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + }, + { + "name": "index_AddressKeyEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_AddressKeyEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [ + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "KeySaltEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `keyId` TEXT NOT NULL, `keySalt` TEXT, PRIMARY KEY(`userId`, `keyId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySalt", + "columnName": "keySalt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "keyId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_KeySaltEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_KeySaltEntity_keyId", + "unique": false, + "columnNames": [ + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_KeySaltEntity_keyId` ON `${TABLE_NAME}` (`keyId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `recipientType` INTEGER NOT NULL, `mimeType` TEXT, `ignoreKT` INTEGER, `signedKeyList_data` TEXT, `signedKeyList_signature` TEXT, `signedKeyList_minEpochId` INTEGER, `signedKeyList_maxEpochId` INTEGER, `signedKeyList_expectedMinEpochId` INTEGER, PRIMARY KEY(`email`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientType", + "columnName": "recipientType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ignoreKT", + "columnName": "ignoreKT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.data", + "columnName": "signedKeyList_data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.signature", + "columnName": "signedKeyList_signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.minEpochId", + "columnName": "signedKeyList_minEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.maxEpochId", + "columnName": "signedKeyList_maxEpochId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "signedKeyListEntity.expectedMinEpochId", + "columnName": "signedKeyList_expectedMinEpochId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "email" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PublicAddressKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `flags` INTEGER NOT NULL, `publicKey` TEXT NOT NULL, `isPrimary` INTEGER NOT NULL, PRIMARY KEY(`email`, `publicKey`), FOREIGN KEY(`email`) REFERENCES `PublicAddressEntity`(`email`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "isPrimary", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "email", + "publicKey" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PublicAddressKeyEntity_email", + "unique": false, + "columnNames": [ + "email" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PublicAddressKeyEntity_email` ON `${TABLE_NAME}` (`email`)" + } + ], + "foreignKeys": [ + { + "table": "PublicAddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "email" + ], + "referencedColumns": [ + "email" + ] + } + ] + }, + { + "tableName": "HumanVerificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`clientId` TEXT NOT NULL, `clientIdType` TEXT NOT NULL, `verificationMethods` TEXT NOT NULL, `verificationToken` TEXT, `state` TEXT NOT NULL, `humanHeaderTokenType` TEXT, `humanHeaderTokenCode` TEXT, PRIMARY KEY(`clientId`))", + "fields": [ + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientIdType", + "columnName": "clientIdType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationMethods", + "columnName": "verificationMethods", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verificationToken", + "columnName": "verificationToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "humanHeaderTokenType", + "columnName": "humanHeaderTokenType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "humanHeaderTokenCode", + "columnName": "humanHeaderTokenCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "clientId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MailSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `displayName` TEXT, `signature` TEXT, `autoSaveContacts` INTEGER, `composerMode` INTEGER, `messageButtons` INTEGER, `showImages` INTEGER, `showMoved` INTEGER, `viewMode` INTEGER, `viewLayout` INTEGER, `swipeLeft` INTEGER, `swipeRight` INTEGER, `shortcuts` INTEGER, `pmSignature` INTEGER, `numMessagePerPage` INTEGER, `draftMimeType` TEXT, `receiveMimeType` TEXT, `showMimeType` TEXT, `enableFolderColor` INTEGER, `inheritParentFolderColor` INTEGER, `rightToLeft` INTEGER, `attachPublicKey` INTEGER, `sign` INTEGER, `pgpScheme` INTEGER, `promptPin` INTEGER, `stickyLabels` INTEGER, `confirmLink` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "autoSaveContacts", + "columnName": "autoSaveContacts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "composerMode", + "columnName": "composerMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageButtons", + "columnName": "messageButtons", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showImages", + "columnName": "showImages", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "showMoved", + "columnName": "showMoved", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewMode", + "columnName": "viewMode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "viewLayout", + "columnName": "viewLayout", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeLeft", + "columnName": "swipeLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "swipeRight", + "columnName": "swipeRight", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shortcuts", + "columnName": "shortcuts", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pmSignature", + "columnName": "pmSignature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "numMessagePerPage", + "columnName": "numMessagePerPage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "draftMimeType", + "columnName": "draftMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiveMimeType", + "columnName": "receiveMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showMimeType", + "columnName": "showMimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enableFolderColor", + "columnName": "enableFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inheritParentFolderColor", + "columnName": "inheritParentFolderColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rightToLeft", + "columnName": "rightToLeft", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachPublicKey", + "columnName": "attachPublicKey", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sign", + "columnName": "sign", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "pgpScheme", + "columnName": "pgpScheme", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "promptPin", + "columnName": "promptPin", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "stickyLabels", + "columnName": "stickyLabels", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "confirmLink", + "columnName": "confirmLink", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "UserSettingsEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `news` INTEGER, `locale` TEXT, `logAuth` INTEGER, `density` INTEGER, `weekStart` INTEGER, `dateFormat` INTEGER, `timeFormat` INTEGER, `earlyAccess` INTEGER, `email_value` TEXT, `email_status` INTEGER, `email_notify` INTEGER, `email_reset` INTEGER, `phone_value` TEXT, `phone_status` INTEGER, `phone_notify` INTEGER, `phone_reset` INTEGER, `password_mode` INTEGER, `password_expirationTime` INTEGER, `twoFA_enabled` INTEGER, `twoFA_allowed` INTEGER, `twoFA_expirationTime` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "news", + "columnName": "news", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "logAuth", + "columnName": "logAuth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "density", + "columnName": "density", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateFormat", + "columnName": "dateFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timeFormat", + "columnName": "timeFormat", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "earlyAccess", + "columnName": "earlyAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.value", + "columnName": "email_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email.status", + "columnName": "email_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.notify", + "columnName": "email_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email.reset", + "columnName": "email_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.value", + "columnName": "phone_value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone.status", + "columnName": "phone_status", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.notify", + "columnName": "phone_notify", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phone.reset", + "columnName": "phone_reset", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.mode", + "columnName": "password_mode", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "password.expirationTime", + "columnName": "password_expirationTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.enabled", + "columnName": "twoFA_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.allowed", + "columnName": "twoFA_allowed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "twoFA.expirationTime", + "columnName": "twoFA_expirationTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT, `planName` TEXT, `twoFactorGracePeriod` INTEGER, `theme` TEXT, `email` TEXT, `maxDomains` INTEGER, `maxAddresses` INTEGER, `maxSpace` INTEGER, `maxMembers` INTEGER, `maxVPN` INTEGER, `maxCalendars` INTEGER, `features` INTEGER, `flags` INTEGER, `usedDomains` INTEGER, `usedAddresses` INTEGER, `usedSpace` INTEGER, `assignedSpace` INTEGER, `usedMembers` INTEGER, `usedVPN` INTEGER, `usedCalendars` INTEGER, `hasKeys` INTEGER, `toMigrate` INTEGER, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "planName", + "columnName": "planName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "twoFactorGracePeriod", + "columnName": "twoFactorGracePeriod", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxDomains", + "columnName": "maxDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxAddresses", + "columnName": "maxAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxSpace", + "columnName": "maxSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMembers", + "columnName": "maxMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxVPN", + "columnName": "maxVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxCalendars", + "columnName": "maxCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "features", + "columnName": "features", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedDomains", + "columnName": "usedDomains", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedAddresses", + "columnName": "usedAddresses", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedSpace", + "columnName": "usedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignedSpace", + "columnName": "assignedSpace", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedMembers", + "columnName": "usedMembers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedVPN", + "columnName": "usedVPN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedCalendars", + "columnName": "usedCalendars", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "hasKeys", + "columnName": "hasKeys", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "toMigrate", + "columnName": "toMigrate", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "OrganizationKeysEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `privateKey` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateKey", + "columnName": "privateKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactId` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`contactId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEntity_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ContactCardEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactId` TEXT NOT NULL, `type` INTEGER NOT NULL, `data` TEXT NOT NULL, `signature` TEXT, `cardId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cardId", + "columnName": "cardId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cardId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_ContactCardEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactCardEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `contactEmailId` TEXT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `defaults` INTEGER NOT NULL, `order` INTEGER NOT NULL, `contactId` TEXT NOT NULL, `canonicalEmail` TEXT, PRIMARY KEY(`contactEmailId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contactId`) REFERENCES `ContactEntity`(`contactId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaults", + "columnName": "defaults", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canonicalEmail", + "columnName": "canonicalEmail", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ContactEmailEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ContactEmailEntity_contactId", + "unique": false, + "columnNames": [ + "contactId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ContactEmailEntity_contactId` ON `${TABLE_NAME}` (`contactId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ContactEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactId" + ], + "referencedColumns": [ + "contactId" + ] + } + ] + }, + { + "tableName": "ContactEmailLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contactEmailId` TEXT NOT NULL, `labelId` TEXT NOT NULL, PRIMARY KEY(`contactEmailId`, `labelId`), FOREIGN KEY(`contactEmailId`) REFERENCES `ContactEmailEntity`(`contactEmailId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "contactEmailId", + "columnName": "contactEmailId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "contactEmailId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "ContactEmailEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contactEmailId" + ], + "referencedColumns": [ + "contactEmailId" + ] + } + ] + }, + { + "tableName": "EventMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `config` TEXT NOT NULL, `eventId` TEXT, `nextEventId` TEXT, `refresh` TEXT, `more` INTEGER, `response` TEXT, `retry` INTEGER NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER, PRIMARY KEY(`userId`, `config`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "nextEventId", + "columnName": "nextEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refresh", + "columnName": "refresh", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "more", + "columnName": "more", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "response", + "columnName": "response", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "retry", + "columnName": "retry", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "config" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_EventMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_EventMetadataEntity_config", + "unique": false, + "columnNames": [ + "config" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_config` ON `${TABLE_NAME}` (`config`)" + }, + { + "name": "index_EventMetadataEntity_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventMetadataEntity_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "LabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `path` TEXT NOT NULL, `color` TEXT NOT NULL, `order` INTEGER NOT NULL, `isNotified` INTEGER, `isExpanded` INTEGER, `isSticky` INTEGER, PRIMARY KEY(`userId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNotified", + "columnName": "isNotified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isSticky", + "columnName": "isSticky", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_LabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_LabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_LabelEntity_parentId", + "unique": false, + "columnNames": [ + "parentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_parentId` ON `${TABLE_NAME}` (`parentId`)" + }, + { + "name": "index_LabelEntity_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_LabelEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LabelEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "FeatureFlagEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `featureId` TEXT NOT NULL, `scope` TEXT NOT NULL, `defaultValue` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`userId`, `featureId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "featureId", + "columnName": "featureId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultValue", + "columnName": "defaultValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "featureId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_FeatureFlagEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_FeatureFlagEntity_featureId", + "unique": false, + "columnNames": [ + "featureId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeatureFlagEntity_featureId` ON `${TABLE_NAME}` (`featureId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ChallengeFrameEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`challengeFrame` TEXT NOT NULL, `flow` TEXT NOT NULL, `focusTime` TEXT NOT NULL, `clicks` INTEGER NOT NULL, `copy` TEXT NOT NULL, `paste` TEXT NOT NULL, `keys` TEXT NOT NULL, PRIMARY KEY(`challengeFrame`))", + "fields": [ + { + "fieldPath": "challengeFrame", + "columnName": "challengeFrame", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "copy", + "columnName": "copy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paste", + "columnName": "paste", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keys", + "columnName": "keys", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "challengeFrame" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `userId` TEXT NOT NULL, `time` INTEGER NOT NULL, `type` TEXT NOT NULL, `payload` TEXT NOT NULL, PRIMARY KEY(`userId`, `notificationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "notificationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_NotificationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_NotificationEntity_notificationId", + "unique": false, + "columnNames": [ + "notificationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_notificationId` ON `${TABLE_NAME}` (`notificationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PushEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `pushId` TEXT NOT NULL, `objectId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `pushId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushId", + "columnName": "pushId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "pushId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PushEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PushEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PushEntity_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "PageIntervalEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `type` TEXT NOT NULL, `orderBy` TEXT NOT NULL, `labelId` TEXT NOT NULL, `keyword` TEXT NOT NULL, `read` TEXT NOT NULL, `minValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `minOrder` INTEGER NOT NULL, `maxOrder` INTEGER NOT NULL, `minId` TEXT, `maxId` TEXT, PRIMARY KEY(`userId`, `type`, `orderBy`, `labelId`, `keyword`, `read`, `minValue`, `maxValue`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderBy", + "columnName": "orderBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minOrder", + "columnName": "minOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxOrder", + "columnName": "maxOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minId", + "columnName": "minId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxId", + "columnName": "maxId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "type", + "orderBy", + "labelId", + "keyword", + "read", + "minValue", + "maxValue" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_PageIntervalEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_PageIntervalEntity_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_PageIntervalEntity_minValue", + "unique": false, + "columnNames": [ + "minValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minValue` ON `${TABLE_NAME}` (`minValue`)" + }, + { + "name": "index_PageIntervalEntity_maxValue", + "unique": false, + "columnNames": [ + "maxValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxValue` ON `${TABLE_NAME}` (`maxValue`)" + }, + { + "name": "index_PageIntervalEntity_minOrder", + "unique": false, + "columnNames": [ + "minOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_minOrder` ON `${TABLE_NAME}` (`minOrder`)" + }, + { + "name": "index_PageIntervalEntity_maxOrder", + "unique": false, + "columnNames": [ + "maxOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageIntervalEntity_maxOrder` ON `${TABLE_NAME}` (`maxOrder`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `unread` INTEGER NOT NULL, `toList` TEXT NOT NULL, `ccList` TEXT NOT NULL, `bccList` TEXT NOT NULL, `time` INTEGER NOT NULL, `size` INTEGER NOT NULL, `expirationTime` INTEGER NOT NULL, `isReplied` INTEGER NOT NULL, `isRepliedAll` INTEGER NOT NULL, `isForwarded` INTEGER NOT NULL, `addressId` TEXT NOT NULL, `externalId` TEXT, `numAttachments` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, `sender_address` TEXT NOT NULL, `sender_name` TEXT NOT NULL, `sender_isProton` INTEGER NOT NULL, `sender_group` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`addressId`) REFERENCES `AddressEntity`(`addressId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toList", + "columnName": "toList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccList", + "columnName": "ccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccList", + "columnName": "bccList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isReplied", + "columnName": "isReplied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRepliedAll", + "columnName": "isRepliedAll", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForwarded", + "columnName": "isForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressId", + "columnName": "addressId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "externalId", + "columnName": "externalId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.address", + "columnName": "sender_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.name", + "columnName": "sender_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender.isProton", + "columnName": "sender_isProton", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sender.group", + "columnName": "sender_group", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageEntity_addressId", + "unique": false, + "columnNames": [ + "addressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageEntity_addressId` ON `${TABLE_NAME}` (`addressId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "AddressEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "addressId" + ], + "referencedColumns": [ + "addressId" + ] + } + ] + }, + { + "tableName": "MessageLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `messageId` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageLabelEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_MessageLabelEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageLabelEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageBodyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `body` TEXT, `header` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `spamScore` TEXT NOT NULL, `replyTo` TEXT NOT NULL, `replyTos` TEXT NOT NULL, `unsubscribeMethodsEntity` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spamScore", + "columnName": "spamScore", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTo", + "columnName": "replyTo", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTos", + "columnName": "replyTos", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsubscribeMethodsEntity", + "columnName": "unsubscribeMethodsEntity", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageBodyEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageBodyEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageBodyEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `disposition` TEXT, `keyPackets` TEXT, `signature` TEXT, `encSignature` TEXT, `headers` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageBodyEntity`(`userId`, `messageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disposition", + "columnName": "disposition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "keyPackets", + "columnName": "keyPackets", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "encSignature", + "columnName": "encSignature", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "headers", + "columnName": "headers", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + }, + { + "name": "index_MessageAttachmentEntity_userId_messageId", + "unique": false, + "columnNames": [ + "userId", + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentEntity_userId_messageId` ON `${TABLE_NAME}` (`userId`, `messageId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "MessageBodyEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId" + ], + "referencedColumns": [ + "userId", + "messageId" + ] + } + ] + }, + { + "tableName": "MessageAttachmentMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `hash` TEXT, `path` TEXT, `status` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES `MessageAttachmentEntity`(`userId`, `messageId`, `attachmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "messageId", + "attachmentId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_MessageAttachmentMetadataEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_MessageAttachmentMetadataEntity_attachmentId", + "unique": false, + "columnNames": [ + "attachmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MessageAttachmentMetadataEntity_attachmentId` ON `${TABLE_NAME}` (`attachmentId`)" + } + ], + "foreignKeys": [ + { + "table": "MessageAttachmentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "messageId", + "attachmentId" + ], + "referencedColumns": [ + "userId", + "messageId", + "attachmentId" + ] + } + ] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `order` INTEGER NOT NULL, `subject` TEXT NOT NULL, `senders` TEXT NOT NULL, `recipients` TEXT NOT NULL, `expirationTime` INTEGER NOT NULL, `numMessages` INTEGER NOT NULL, `numUnread` INTEGER NOT NULL, `numAttachments` INTEGER NOT NULL, `attachmentCount` TEXT NOT NULL, PRIMARY KEY(`userId`, `conversationId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senders", + "columnName": "senders", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipients", + "columnName": "recipients", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationTime", + "columnName": "expirationTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numMessages", + "columnName": "numMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numUnread", + "columnName": "numUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "numAttachments", + "columnName": "numAttachments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentCount", + "columnName": "attachmentCount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "ConversationLabelEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `labelId` TEXT NOT NULL, `contextTime` INTEGER NOT NULL, `contextSize` INTEGER NOT NULL, `contextNumMessages` INTEGER NOT NULL, `contextNumUnread` INTEGER NOT NULL, `contextNumAttachments` INTEGER NOT NULL, PRIMARY KEY(`userId`, `conversationId`, `labelId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`userId`, `conversationId`) REFERENCES `ConversationEntity`(`userId`, `conversationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "labelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextTime", + "columnName": "contextTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextSize", + "columnName": "contextSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumMessages", + "columnName": "contextNumMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumUnread", + "columnName": "contextNumUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextNumAttachments", + "columnName": "contextNumAttachments", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "conversationId", + "labelId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_ConversationLabelEntity_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_ConversationLabelEntity_labelId", + "unique": false, + "columnNames": [ + "labelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_labelId` ON `${TABLE_NAME}` (`labelId`)" + }, + { + "name": "index_ConversationLabelEntity_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + }, + { + "name": "index_ConversationLabelEntity_userId_conversationId", + "unique": false, + "columnNames": [ + "userId", + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ConversationLabelEntity_userId_conversationId` ON `${TABLE_NAME}` (`userId`, `conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + }, + { + "table": "ConversationEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId", + "conversationId" + ], + "referencedColumns": [ + "userId", + "conversationId" + ] + } + ] + }, + { + "tableName": "GooglePurchaseEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`googlePurchaseToken` TEXT NOT NULL, `paymentToken` TEXT NOT NULL, PRIMARY KEY(`googlePurchaseToken`))", + "fields": [ + { + "fieldPath": "googlePurchaseToken", + "columnName": "googlePurchaseToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paymentToken", + "columnName": "paymentToken", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "googlePurchaseToken" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_GooglePurchaseEntity_paymentToken", + "unique": true, + "columnNames": [ + "paymentToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GooglePurchaseEntity_paymentToken` ON `${TABLE_NAME}` (`paymentToken`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ObservabilityEventEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AddressChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `changeId` TEXT NOT NULL, `counterEncrypted` TEXT NOT NULL, `emailEncrypted` TEXT NOT NULL, `epochIdEncrypted` TEXT NOT NULL, `creationTimestampEncrypted` TEXT NOT NULL, `publicKeysEncrypted` TEXT NOT NULL, `isObsolete` TEXT NOT NULL, PRIMARY KEY(`userId`, `changeId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changeId", + "columnName": "changeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counterEncrypted", + "columnName": "counterEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emailEncrypted", + "columnName": "emailEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochIdEncrypted", + "columnName": "epochIdEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creationTimestampEncrypted", + "columnName": "creationTimestampEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeysEncrypted", + "columnName": "publicKeysEncrypted", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isObsolete", + "columnName": "isObsolete", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId", + "changeId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + }, + { + "tableName": "SelfAuditResultEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "userId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "UserEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "userId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '188ba1ddab28818ed047076b010c6b15')" + ] + } +} \ No newline at end of file diff --git a/app/src/alpha/ic_launcher-playstore.png b/app/src/alpha/ic_launcher-playstore.png new file mode 100644 index 0000000000..0fd0588b61 Binary files /dev/null and b/app/src/alpha/ic_launcher-playstore.png differ diff --git a/app/src/alpha/res/drawable-v24/ic_launcher_foreground.xml b/app/src/alpha/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..f0275fcdf7 --- /dev/null +++ b/app/src/alpha/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..083ceff9ab --- /dev/null +++ b/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..083ceff9ab --- /dev/null +++ b/app/src/alpha/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/alpha/res/mipmap-hdpi/ic_launcher.png b/app/src/alpha/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..71da56590b Binary files /dev/null and b/app/src/alpha/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/alpha/res/mipmap-hdpi/ic_launcher_round.png b/app/src/alpha/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..920d02bd73 Binary files /dev/null and b/app/src/alpha/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/alpha/res/mipmap-mdpi/ic_launcher.png b/app/src/alpha/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..de5008c550 Binary files /dev/null and b/app/src/alpha/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/alpha/res/mipmap-mdpi/ic_launcher_round.png b/app/src/alpha/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..7882f48703 Binary files /dev/null and b/app/src/alpha/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..1c3ce3af3f Binary files /dev/null and b/app/src/alpha/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/alpha/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/alpha/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..7fe445965a Binary files /dev/null and b/app/src/alpha/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..410b9a8216 Binary files /dev/null and b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/alpha/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..d75da0c0fa Binary files /dev/null and b/app/src/alpha/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..a935de9224 Binary files /dev/null and b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..4c0cba3656 Binary files /dev/null and b/app/src/alpha/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/androidTest/java/ch/protonmail/android/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ch/protonmail/android/ExampleInstrumentedTest.kt deleted file mode 100644 index 86773dd925..0000000000 --- a/app/src/androidTest/java/ch/protonmail/android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ch.protonmail.android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("ch.protonmail.android", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/db/BaseDatabaseTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/db/BaseDatabaseTest.kt new file mode 100644 index 0000000000..9fb67ce997 --- /dev/null +++ b/app/src/androidTest/kotlin/ch/protonmail/android/db/BaseDatabaseTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.db + +import androidx.room.Room +import androidx.room.withTransaction +import androidx.test.core.app.ApplicationProvider +import ch.protonmail.android.mailcommon.data.sample.AccountEntitySample +import ch.protonmail.android.mailcommon.data.sample.AddressEntitySample +import ch.protonmail.android.mailcommon.data.sample.SessionEntitySample +import ch.protonmail.android.mailcommon.data.sample.UserEntitySample +import kotlin.test.AfterTest + +@Suppress("UnnecessaryAbstractClass", "MemberVisibilityCanBePrivate") +abstract class BaseDatabaseTest { + + protected val database by lazy { + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java + ).build() + } + protected val accountDao by lazy { database.accountDao() } + protected val addressDao by lazy { database.addressDao() } + protected val messageDao by lazy { database.messageDao() } + protected val messageLabelDao by lazy { database.messageLabelDao() } + protected val labelDao by lazy { database.labelDao() } + protected val sessionDao by lazy { database.sessionDao() } + protected val userDao by lazy { database.userDao() } + + @AfterTest + fun teardown() { + database.close() + } + + protected suspend fun insertPrimaryUser() { + accountDao.insertOrIgnore(AccountEntitySample.PrimaryNotReady) + database.withTransaction { + sessionDao.insertOrIgnore(SessionEntitySample.Primary) + accountDao.insertOrUpdate(AccountEntitySample.Primary) + userDao.insertOrIgnore(UserEntitySample.Primary) + } + addressDao.insertOrIgnore(AddressEntitySample.Primary) + } +} diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/db/MessageDaoTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/db/MessageDaoTest.kt new file mode 100644 index 0000000000..d3fd08c537 --- /dev/null +++ b/app/src/androidTest/kotlin/ch/protonmail/android/db/MessageDaoTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.db + +import androidx.room.withTransaction +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.data.sample.LabelEntitySample +import ch.protonmail.android.mailcommon.domain.sample.ConversationIdSample +import ch.protonmail.android.mailcommon.domain.sample.LabelIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.data.local.relation.MessageWithLabelIds +import ch.protonmail.android.mailmessage.data.sample.MessageEntitySample +import ch.protonmail.android.mailmessage.data.sample.MessageLabelEntitySample +import ch.protonmail.android.mailmessage.data.sample.MessageWithLabelIdsSample +import ch.protonmail.android.test.annotations.suite.SmokeTest +import kotlinx.coroutines.runBlocking +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals + +@SmokeTest +internal class MessageDaoTest : BaseDatabaseTest() { + + private val allMessages = listOf( + MessageWithLabelIdsSample.AugWeatherForecast, + MessageWithLabelIdsSample.Invoice, + MessageWithLabelIdsSample.SepWeatherForecast + ) + + @BeforeTest + fun setup() { + runBlocking { setupDatabaseWithMessages() } + } + + @Test + fun findAllByAsc() = runBlocking { + // given + val expected = allMessages.sortedBy { it.message.time } + + // when + messageDao.observeAllOrderByTimeAsc( + userId = UserIdSample.Primary + ).test { + + // then + assertMessagesEquals(expected) + } + } + + @Test + fun findAllByDesc() = runBlocking { + // given + val expected = allMessages.sortedByDescending { it.message.time } + + // when + messageDao.observeAllOrderByTimeDesc( + userId = UserIdSample.Primary + ).test { + + // then + assertMessagesEquals(expected) + } + } + + @Test + fun findAllByLabelIdByDesc() = runBlocking { + // given + val labelId = LabelIdSample.Document + val expected = allMessages + .filter { labelId in it.labelIds } + .sortedByDescending { it.message.time } + + // when + messageDao.observeAllOrderByTimeDesc( + userId = UserIdSample.Primary, + labelId = labelId + ).test { + + // then + assertMessagesEquals(expected) + } + } + + @Test + fun findAllByLabelIdByAsc() = runBlocking { + // given + val labelId = LabelIdSample.Document + val expected = allMessages + .filter { labelId in it.labelIds } + .sortedBy { it.message.time } + + // when + messageDao.observeAllOrderByTimeAsc( + userId = UserIdSample.Primary, + labelId = labelId + ).test { + + // then + assertMessagesEquals(expected) + } + } + + @Test + fun findAllByConversationIdByDesc() = runBlocking { + // given + val conversationId = ConversationIdSample.WeatherForecast + val expected = allMessages + .filter { it.message.conversationId == conversationId } + .sortedByDescending { it.message.time } + + // when + messageDao.observeAllOrderByTimeDesc( + userId = UserIdSample.Primary, + conversationId = conversationId + ).test { + + // then + assertMessagesEquals(expected) + } + } + + @Test + fun findAllByConversationIdByAsc() = runBlocking { + // given + val conversationId = ConversationIdSample.WeatherForecast + val expected = allMessages + .filter { it.message.conversationId == conversationId } + .sortedBy { it.message.time } + + // when + messageDao.observeAllOrderByTimeAsc( + userId = UserIdSample.Primary, + conversationId = conversationId + ).test { + + // then + assertMessagesEquals(expected) + } + } + + private suspend fun setupDatabaseWithMessages() { + database.withTransaction { + insertPrimaryUser() + with(labelDao) { + insertOrIgnore(LabelEntitySample.Archive) + insertOrIgnore(LabelEntitySample.Document) + } + with(messageDao) { + insertOrIgnore(MessageEntitySample.AugWeatherForecast) + insertOrIgnore(MessageEntitySample.Invoice) + insertOrIgnore(MessageEntitySample.SepWeatherForecast) + } + with(messageLabelDao) { + insertOrIgnore(MessageLabelEntitySample.AugWeatherForecastArchive) + insertOrIgnore(MessageLabelEntitySample.InvoiceArchive) + insertOrIgnore(MessageLabelEntitySample.InvoiceDocument) + insertOrIgnore(MessageLabelEntitySample.SepWeatherForecastArchive) + } + } + } + + private suspend fun ReceiveTurbine>.assertMessagesEquals( + expected: List + ) { + val actual = awaitItem() + assertContentEquals(expected, actual, buildMessage(expected, actual)) + } + + private fun buildMessage(expected: List, actual: List) = + """Expected ${expected.size} messages, but got ${actual.size} messages + |Expected: ${expected.map { it.message.messageId.id }} + |Actual: ${actual.map { it.message.messageId.id }} + | + """.trimMargin() +} diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/db/MigrationTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/db/MigrationTest.kt new file mode 100644 index 0000000000..c40a17555b --- /dev/null +++ b/app/src/androidTest/kotlin/ch/protonmail/android/db/MigrationTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.db + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import ch.protonmail.android.test.annotations.suite.SmokeTest +import org.junit.Rule +import org.junit.Test + +@SmokeTest +internal class MigrationTest { + + private val testDb = "migration-test" + + @Rule + @JvmField + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java + ) + + @Test + fun migrateAll() { + // Create earliest version of the database. + helper.createDatabase(testDb, 1).apply { + close() + } + + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + testDb + ).addMigrations(*AppDatabase.migrations.toTypedArray()).build().apply { + openHelper.writableDatabase + close() + } + } +} diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandlerTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandlerTest.kt new file mode 100644 index 0000000000..4d2b42f25f --- /dev/null +++ b/app/src/androidTest/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandlerTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.forceupdate + +import android.content.Context +import ch.protonmail.android.test.annotations.suite.SmokeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import me.proton.core.presentation.app.AppLifecycleObserver +import me.proton.core.presentation.app.AppLifecycleProvider +import org.junit.Before +import org.junit.Test + +@SmokeTest +internal class ForceUpdateHandlerTest { + + private val context = mockk(relaxed = true) + private val appState = MutableStateFlow(AppLifecycleProvider.State.Background) + + private val appLifecycleObserver = mockk(relaxUnitFun = true) { + every { state } returns appState + } + + private lateinit var forceUpdateHandler: ForceUpdateHandler + + @Before + fun setUp() { + forceUpdateHandler = ForceUpdateHandler(context, appLifecycleObserver) + } + + @Test + fun whenForegroundOnForceUpdate() { + // GIVEN + appState.tryEmit(AppLifecycleProvider.State.Foreground) + // WHEN + forceUpdateHandler.onForceUpdate("Update") + // THEN + verify(atLeast = 1) { context.startActivity(any()) } + } + + @Test + fun whenBackgroundOnForceUpdate() { + // GIVEN + appState.tryEmit(AppLifecycleProvider.State.Background) + // WHEN + forceUpdateHandler.onForceUpdate("Update") + // THEN + verify(exactly = 0) { context.startActivity(any()) } + } +} diff --git a/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt b/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt new file mode 100644 index 0000000000..fd52fe712f --- /dev/null +++ b/app/src/androidTest/kotlin/ch/protonmail/android/message/SplitMessageBodyQuoteTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.message + +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.usecase.SplitMessageBodyHtmlQuote +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@SmokeTest +class SplitMessageBodyQuoteTest { + + private val splitMessageBodyHtmlQuote = SplitMessageBodyHtmlQuote() + + @Test + fun returnsGivenDecryptedBodyAndNoQuoteWhenMimeTypeIsPlainText() { + // Given + val decryptedBody = DecryptedMessageBodyTestData.PlainTextDecryptedBody + + // When + val actual = splitMessageBodyHtmlQuote(decryptedBody) + + // Then + assertEquals(Pair(DraftBody(decryptedBody.value), null), actual) + } + + @Test + fun returnsDecryptedBodyTextExtractedFromHtmlAndNoQuoteWhenTheInputBodyHasNoQuoteAnchors() { + // Given + val decryptedBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody( + value = HtmlBodyWithNoQuoteAnchors, + mimeType = MimeType.Html + ) + + // When + val actual = splitMessageBodyHtmlQuote(decryptedBody) + + // Then + val expected = DraftBody("$BodyTypedContentRaw \n$BodyMoreTypedContentRaw \n") + assertEquals(Pair(expected, null), actual) + } + + @Test + fun returnsNonHtmlTextExtractedFromHtmlAndQuoteWhenTheInputBodyHasOneOfTheQuoteAnchors() { + // Given + val decryptedBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody( + value = HtmlBodyWithProtonQuoteAnchor, + mimeType = MimeType.Html + ) + + // When + val actual = splitMessageBodyHtmlQuote(decryptedBody) + val actualQuote = actual.second!! + + // Then + assertEquals(DraftBody(BodyTypedContentRaw), actual.first) + assertTrue(actualQuote.value.contains(ProtonQuoteAnchor)) + assertTrue(actualQuote.value.contains(HtmlQuotedContentRaw)) + } + + companion object { + private const val BodyTypedContentRaw = "Typed in content" + private const val BodyMoreTypedContentRaw = "this is additional typed content" + private const val HtmlQuotedContentRaw = "Any quoted html content here" + private const val ProtonQuoteAnchor = "
" + private const val ProtonQuoteClosingAnchor = "
" + + private const val ProtonQuoteHtml = "$ProtonQuoteAnchor$HtmlQuotedContentRaw$ProtonQuoteClosingAnchor" + + private const val HtmlBodyWithNoQuoteAnchors = """ + + + $BodyTypedContentRaw +
$BodyMoreTypedContentRaw
+ + + """ + + private const val HtmlBodyWithProtonQuoteAnchor = """ + + + $BodyTypedContentRaw + $ProtonQuoteHtml + + + """ + } +} diff --git a/app/src/dev/AndroidManifest.xml b/app/src/dev/AndroidManifest.xml new file mode 100644 index 0000000000..d562e80937 --- /dev/null +++ b/app/src/dev/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/app/src/dev/ic_launcher-playstore.png b/app/src/dev/ic_launcher-playstore.png new file mode 100644 index 0000000000..f5d252178a Binary files /dev/null and b/app/src/dev/ic_launcher-playstore.png differ diff --git a/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt b/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt new file mode 100644 index 0000000000..3abb5c1ce2 --- /dev/null +++ b/app/src/dev/kotlin/ch/protonmail/android/di/ServerProofModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.auth.domain.usecase.ValidateServerProof + +/** + * This module is an explicit provider for [ValidateServerProof] that is needed to perform the required + * overrides when running UI Tests. + * + * There's no need to have this definition in the production code, as the dependency is still automatically provided. + */ +@Module +@InstallIn(SingletonComponent::class) +object ServerProofModule { + + @Provides + fun provideServerProofValidation(): ValidateServerProof = ValidateServerProof() +} diff --git a/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml b/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..abc4ebc37d --- /dev/null +++ b/app/src/dev/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..083ceff9ab --- /dev/null +++ b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..083ceff9ab --- /dev/null +++ b/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/app/src/dev/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..29edd9c0f4 Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..a921b8b78b Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/app/src/dev/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..175dbdc2ad Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..bab228e599 Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..8f84b52977 Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..f947ffc439 Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..e0f214062f Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..33643f2454 Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..61d05e3a94 Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..ae270d96b0 Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/dev/res/xml/pm_network_security_config.xml b/app/src/dev/res/xml/pm_network_security_config.xml new file mode 100644 index 0000000000..9e269d35cf --- /dev/null +++ b/app/src/dev/res/xml/pm_network_security_config.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8dcee12aa5..1b8f8b6f28 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,23 +1,310 @@ - + + + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + + + + + + + android:supportsRtl="false" + android:taskAffinity="" + + android:theme="@style/ProtonTheme.Mail" + tools:replace="android:theme,android:supportsRtl"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:exported="true" + android:launchMode="standard" + android:theme="@style/ProtonTheme.Splash.Mail" + android:windowSoftInputMode="adjustResize"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..9af7a3e84a Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/ch/protonmail/android/MainActivity.kt b/app/src/main/java/ch/protonmail/android/MainActivity.kt deleted file mode 100644 index 5cc421fb4e..0000000000 --- a/app/src/main/java/ch/protonmail/android/MainActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ch.protonmail.android - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/ch/protonmail/android/App.kt b/app/src/main/kotlin/ch/protonmail/android/App.kt new file mode 100644 index 0000000000..41b7122de6 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/App.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android + +import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner +import ch.protonmail.android.callbacks.AutoLockLifecycleCallbacks +import ch.protonmail.android.callbacks.SecureActivityLifecycleCallbacks +import ch.protonmail.android.initializer.MainInitializer +import ch.protonmail.android.logging.LogsFileHandlerLifecycleObserver +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue +import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracer +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import javax.inject.Provider + +@HiltAndroidApp +internal class App : Application() { + + @Inject + lateinit var secureActivityLifecycleCallbacks: SecureActivityLifecycleCallbacks + + @Inject + lateinit var lockScreenLifecycleCallbacks: AutoLockLifecycleCallbacks + + @Inject + lateinit var benchmarkTracer: BenchmarkTracer + + @Inject + @LogsExportFeatureSettingValue + lateinit var logsExportFeatureSetting: Provider + + override fun onCreate() { + super.onCreate() + + benchmarkTracer.begin("proton-app-init") + + MainInitializer.init(this) + registerActivityLifecycleCallbacks(secureActivityLifecycleCallbacks) + registerActivityLifecycleCallbacks(lockScreenLifecycleCallbacks) + + addLogsFileHandlerObserver() + + benchmarkTracer.end() + } + + private fun addLogsFileHandlerObserver() { + if (logsExportFeatureSetting.get().isEnabled) { + ProcessLifecycleOwner.get().lifecycle.addObserver(LogsFileHandlerLifecycleObserver(this)) + } + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt b/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt new file mode 100644 index 0000000000..aa95c030cd --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/LockScreenActivity.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android + +import java.util.concurrent.Executors +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import ch.protonmail.android.mailsettings.domain.model.autolock.AutoLockInsertionMode +import ch.protonmail.android.mailsettings.domain.model.autolock.biometric.BiometricPromptCallback +import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect +import ch.protonmail.android.navigation.model.Destination +import ch.protonmail.android.navigation.route.addAutoLockPinScreen +import dagger.hilt.android.AndroidEntryPoint +import io.sentry.compose.withSentryObservableEffect +import me.proton.core.compose.theme.ProtonTheme + +@AndroidEntryPoint +internal class LockScreenActivity : AppCompatActivity() { + + private var biometricPrompt: BiometricPrompt? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ProtonTheme { + val navController = rememberNavController() + .withSentryObservableEffect() + .withDestinationChangedObservableEffect() + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = Destination.Screen.AutoLockPinScreen.route + ) { + addAutoLockPinScreen( + onShowSuccessSnackbar = {}, + onBack = { this@LockScreenActivity.finish() }, + activityActions = Actions( + finishActivity = { finish() }, + showBiometricPrompt = { callback -> + showBiometricPrompt(callback) + } + ) + ) + } + + navController.navigate( + Destination.Screen.AutoLockPinScreen(AutoLockInsertionMode.VerifyPin) + ) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + } + } + + private fun showBiometricPrompt(callback: BiometricPromptCallback) { + val executor = Executors.newSingleThreadExecutor() + biometricPrompt = BiometricPrompt( + this, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + callback.onAuthenticationError() + + if (!this@LockScreenActivity.isFinishing && + lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + ) { + biometricPrompt?.cancelAuthentication() + biometricPrompt = null + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + + callback.onAuthenticationSucceeded() + } + } + ) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.app_locked)) + .setDescription(getString(R.string.log_in_using_biometric_credential)) + .setNegativeButtonText(getString(R.string.use_pin_instead)) + .build() + biometricPrompt?.authenticate(promptInfo) + } + + + data class Actions( + val finishActivity: () -> Unit, + val showBiometricPrompt: (BiometricPromptCallback) -> Unit + ) { + + companion object { + + val Empty = Actions( + finishActivity = {}, + showBiometricPrompt = {} + ) + } + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt b/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt new file mode 100644 index 0000000000..06aa8e2964 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/MainActivity.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import ch.protonmail.android.feature.postsubscription.ObservePostSubscription +import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities +import ch.protonmail.android.mailcommon.presentation.system.LocalDeviceCapabilitiesProvider +import ch.protonmail.android.maildetail.domain.model.OpenAttachmentIntentValues +import ch.protonmail.android.maildetail.domain.model.OpenProtonCalendarIntentValues +import ch.protonmail.android.maildetail.presentation.util.ProtonCalendarUtil +import ch.protonmail.android.navigation.Launcher +import ch.protonmail.android.navigation.LauncherViewModel +import ch.protonmail.android.navigation.model.LauncherState +import ch.protonmail.android.navigation.share.ShareIntentObserver +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.notification.presentation.deeplink.DeeplinkManager +import me.proton.core.notification.presentation.deeplink.onActivityCreate +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @Inject + lateinit var deeplinkManager: DeeplinkManager + + @Inject + lateinit var deviceCapabilities: DeviceCapabilities + + @Inject + lateinit var shareIntentObserver: ShareIntentObserver + + @Inject + lateinit var observePostSubscription: ObservePostSubscription + + private val launcherViewModel: LauncherViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen().setKeepOnScreenCondition { + launcherViewModel.state.value == LauncherState.Processing + } + super.onCreate(savedInstanceState) + + deeplinkManager.onActivityCreate(this, savedInstanceState) + + // Register activities for result. + launcherViewModel.register(this) + + lifecycleScope.launch { + observePostSubscription.start(this@MainActivity) + } + + shareIntentObserver.onNewIntent(intent) + + disableRecentAppsScreenshotPreview() + + setContent { + ProtonTheme { + CompositionLocalProvider( + LocalDeviceCapabilitiesProvider provides deviceCapabilities.getCapabilities() + ) { + Launcher( + Actions( + openInActivityInNewTask = { openInActivityInNewTask(it) }, + openIntentChooser = { openIntentChooser(it) }, + openProtonCalendarIntentValues = { handleProtonCalendarRequest(it) }, + openSecurityKeys = { launcherViewModel.submit(LauncherViewModel.Action.OpenSecurityKeys) }, + finishActivity = { finishAfterTransition() } + ), + launcherViewModel + ) + } + } + } + } + + override fun onDestroy() { + launcherViewModel.unregister() + super.onDestroy() + } + + private fun disableRecentAppsScreenshotPreview() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(BuildConfig.DEBUG) + } + } + + private fun openInActivityInNewTask(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.d(e, "Failed to open a browser app") + + Toast.makeText( + this, + getString(R.string.intent_failure_no_app_found_to_handle_this_action), + Toast.LENGTH_LONG + ).show() + } + } + + private fun openIntentChooser(intentValues: OpenAttachmentIntentValues) { + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(intentValues.uri, intentValues.mimeType) + .putExtra(Intent.EXTRA_STREAM, intentValues.uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.d(e, "Failed to open intent for file type") + startActivity(Intent.createChooser(intent, null)) + } + } + + private fun handleProtonCalendarRequest(values: OpenProtonCalendarIntentValues) { + val intent = when (values) { + is OpenProtonCalendarIntentValues.OpenIcsInProtonCalendar -> + ProtonCalendarUtil.getIntentToOpenIcsInProtonCalendar( + values.uriToIcsAttachment, + values.sender, + values.recipient + ) + + is OpenProtonCalendarIntentValues.OpenProtonCalendarOnPlayStore -> + ProtonCalendarUtil.getIntentToProtonCalendarOnPlayStore() + } + startActivity(intent) + } + + data class Actions( + val openInActivityInNewTask: (uri: Uri) -> Unit, + val openIntentChooser: (values: OpenAttachmentIntentValues) -> Unit, + val openProtonCalendarIntentValues: (values: OpenProtonCalendarIntentValues) -> Unit, + val openSecurityKeys: () -> Unit, + val finishActivity: () -> Unit + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt b/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt new file mode 100644 index 0000000000..eea9ecb3a5 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/PostSubscriptionActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect +import ch.protonmail.android.navigation.model.Destination +import ch.protonmail.android.navigation.route.addPostSubscription +import dagger.hilt.android.AndroidEntryPoint +import io.sentry.compose.withSentryObservableEffect +import me.proton.core.compose.theme.ProtonTheme + +@AndroidEntryPoint +class PostSubscriptionActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ProtonTheme { + val navController = rememberNavController() + .withSentryObservableEffect() + .withDestinationChangedObservableEffect() + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = Destination.Screen.PostSubscription.route + ) { + addPostSubscription( + onClose = { this@PostSubscriptionActivity.finish() } + ) + } + + navController.navigate( + Destination.Screen.PostSubscription.route + ) + } + } + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt b/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt new file mode 100644 index 0000000000..dcc09810d3 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/callbacks/AutoLockLifecycleCallbacks.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.callbacks + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import ch.protonmail.android.LockScreenActivity +import ch.protonmail.android.mailcommon.domain.coroutines.AppScope +import ch.protonmail.android.mailsettings.domain.usecase.autolock.ShouldPresentPinInsertionScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class AutoLockLifecycleCallbacks @Inject constructor( + private val shouldPresentPinInsertionScreen: ShouldPresentPinInsertionScreen, + @AppScope private val coroutineScope: CoroutineScope +) : Application.ActivityLifecycleCallbacks { + + private var job: Job? = null + + override fun onActivityResumed(activity: Activity) { + if (activity is LockScreenActivity) return + + job = coroutineScope.launch { + shouldPresentPinInsertionScreen().collectLatest { forcePinInsertion -> + if (!forcePinInsertion) return@collectLatest + val intent = Intent(activity, LockScreenActivity::class.java) + activity.startActivity(intent) + } + } + } + + override fun onActivityPaused(p0: Activity) { + job?.cancel() + job = null + } + + override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit + override fun onActivityStarted(p0: Activity) = Unit + override fun onActivityStopped(p0: Activity) = Unit + override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit + override fun onActivityDestroyed(p0: Activity) = Unit +} diff --git a/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt b/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt new file mode 100644 index 0000000000..0bb2191956 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/callbacks/SecureActivityLifecycleCallbacks.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.callbacks + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import android.view.WindowManager +import arrow.core.getOrElse +import ch.protonmail.android.LockScreenActivity +import ch.protonmail.android.mailcommon.domain.coroutines.AppScope +import ch.protonmail.android.mailsettings.domain.usecase.privacy.ObservePreventScreenshotsSetting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.proton.core.auth.presentation.ui.AuthActivity +import me.proton.core.usersettings.presentation.ui.PasswordManagementActivity +import timber.log.Timber +import javax.inject.Inject + +internal class SecureActivityLifecycleCallbacks @Inject constructor( + private val observePreventScreenshotsSetting: ObservePreventScreenshotsSetting, + @AppScope private val coroutineScope: CoroutineScope +) : ActivityLifecycleCallbacks { + + private var setSecureJob: Job? = null + + override fun onActivityResumed(activity: Activity) { + // Regardless of the user-defined setting, a subset of activities will always be secure. + if (activity.isSecureActivity()) { + setSecureFlags(activity) + return + } + + setSecureJob = coroutineScope.launch { + observePreventScreenshotsSetting().collectLatest { + val preventTakingScreenshotsPreference = it.getOrElse { + Timber.e("Unable to get 'Prevent taking screenshots' setting.") + return@collectLatest + } + + withContext(Dispatchers.Main) { + if (preventTakingScreenshotsPreference.isEnabled) { + setSecureFlags(activity) + } else { + clearSecureFlags(activity) + } + } + } + } + } + + override fun onActivityPaused(p0: Activity) { + setSecureJob?.cancel() + setSecureJob = null + } + + override fun onActivityCreated(p0: Activity, p1: Bundle?) = Unit + override fun onActivityStarted(p0: Activity) = Unit + override fun onActivityStopped(p0: Activity) = Unit + override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) = Unit + override fun onActivityDestroyed(p0: Activity) = Unit + + private fun setSecureFlags(activity: Activity) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } + + private fun clearSecureFlags(activity: Activity) { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + private fun Activity.isSecureActivity(): Boolean = when (this) { + is AuthActivity<*>, + is PasswordManagementActivity, + is LockScreenActivity -> true + + else -> false + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt new file mode 100644 index 0000000000..e4d3c9374c --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabase.kt @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.db + +import android.content.Context +import androidx.room.Database +import androidx.room.TypeConverters +import ch.protonmail.android.composer.data.local.DraftStateDatabase +import ch.protonmail.android.composer.data.local.converters.AttachmentStateConverters +import ch.protonmail.android.composer.data.local.converters.DraftStateConverters +import ch.protonmail.android.composer.data.local.entity.MessageExpirationTimeEntity +import ch.protonmail.android.composer.data.local.entity.MessagePasswordEntity +import ch.protonmail.android.mailconversation.data.local.ConversationDatabase +import ch.protonmail.android.mailconversation.data.local.converters.ConversationConverters +import ch.protonmail.android.mailconversation.data.local.converters.MapConverters +import ch.protonmail.android.mailconversation.data.local.entity.ConversationEntity +import ch.protonmail.android.mailconversation.data.local.entity.ConversationLabelEntity +import ch.protonmail.android.mailconversation.data.local.entity.UnreadConversationsCountEntity +import ch.protonmail.android.mailmessage.data.local.MessageConverters +import ch.protonmail.android.mailmessage.data.local.MessageDatabase +import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase +import ch.protonmail.android.mailmessage.data.local.converters.AttachmentWorkerStatusConverters +import ch.protonmail.android.mailmessage.data.local.converters.UriConverter +import ch.protonmail.android.mailmessage.data.local.entity.AttachmentStateEntity +import ch.protonmail.android.mailmessage.data.local.entity.DraftStateEntity +import ch.protonmail.android.mailmessage.data.local.entity.MessageAttachmentEntity +import ch.protonmail.android.mailmessage.data.local.entity.MessageAttachmentMetadataEntity +import ch.protonmail.android.mailmessage.data.local.entity.MessageBodyEntity +import ch.protonmail.android.mailmessage.data.local.entity.MessageEntity +import ch.protonmail.android.mailmessage.data.local.entity.MessageLabelEntity +import ch.protonmail.android.mailmessage.data.local.entity.SearchResultEntity +import ch.protonmail.android.mailmessage.data.local.entity.UnreadMessagesCountEntity +import ch.protonmail.android.mailpagination.data.local.PageIntervalDatabase +import ch.protonmail.android.mailpagination.data.local.entity.PageIntervalEntity +import me.proton.core.account.data.db.AccountConverters +import me.proton.core.account.data.db.AccountDatabase +import me.proton.core.account.data.entity.AccountEntity +import me.proton.core.account.data.entity.AccountMetadataEntity +import me.proton.core.account.data.entity.SessionDetailsEntity +import me.proton.core.account.data.entity.SessionEntity +import me.proton.core.auth.data.db.AuthConverters +import me.proton.core.auth.data.db.AuthDatabase +import me.proton.core.auth.data.entity.AuthDeviceEntity +import me.proton.core.auth.data.entity.DeviceSecretEntity +import me.proton.core.auth.data.entity.MemberDeviceEntity +import me.proton.core.challenge.data.db.ChallengeConverters +import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.challenge.data.entity.ChallengeFrameEntity +import me.proton.core.contact.data.local.db.ContactConverters +import me.proton.core.contact.data.local.db.ContactDatabase +import me.proton.core.contact.data.local.db.entity.ContactCardEntity +import me.proton.core.contact.data.local.db.entity.ContactEmailEntity +import me.proton.core.contact.data.local.db.entity.ContactEmailLabelEntity +import me.proton.core.contact.data.local.db.entity.ContactEntity +import me.proton.core.crypto.android.keystore.CryptoConverters +import me.proton.core.data.room.db.BaseDatabase +import me.proton.core.data.room.db.CommonConverters +import me.proton.core.eventmanager.data.db.EventManagerConverters +import me.proton.core.eventmanager.data.db.EventMetadataDatabase +import me.proton.core.eventmanager.data.entity.EventMetadataEntity +import me.proton.core.featureflag.data.db.FeatureFlagDatabase +import me.proton.core.featureflag.data.entity.FeatureFlagEntity +import me.proton.core.humanverification.data.db.HumanVerificationConverters +import me.proton.core.humanverification.data.db.HumanVerificationDatabase +import me.proton.core.humanverification.data.entity.HumanVerificationEntity +import me.proton.core.key.data.db.KeySaltDatabase +import me.proton.core.key.data.db.PublicAddressDatabase +import me.proton.core.key.data.entity.KeySaltEntity +import me.proton.core.key.data.entity.PublicAddressEntity +import me.proton.core.key.data.entity.PublicAddressInfoEntity +import me.proton.core.key.data.entity.PublicAddressKeyDataEntity +import me.proton.core.key.data.entity.PublicAddressKeyEntity +import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase +import me.proton.core.keytransparency.data.local.entity.AddressChangeEntity +import me.proton.core.keytransparency.data.local.entity.SelfAuditResultEntity +import me.proton.core.label.data.local.LabelConverters +import me.proton.core.label.data.local.LabelDatabase +import me.proton.core.label.data.local.LabelEntity +import me.proton.core.mailsettings.data.db.MailSettingsDatabase +import me.proton.core.mailsettings.data.entity.MailSettingsEntity +import me.proton.core.notification.data.local.db.NotificationConverters +import me.proton.core.notification.data.local.db.NotificationDatabase +import me.proton.core.notification.data.local.db.NotificationEntity +import me.proton.core.observability.data.db.ObservabilityDatabase +import me.proton.core.observability.data.entity.ObservabilityEventEntity +import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.payment.data.local.entity.GooglePurchaseEntity +import me.proton.core.payment.data.local.entity.PurchaseEntity +import me.proton.core.push.data.local.db.PushConverters +import me.proton.core.push.data.local.db.PushDatabase +import me.proton.core.push.data.local.db.PushEntity +import me.proton.core.telemetry.data.db.TelemetryDatabase +import me.proton.core.telemetry.data.entity.TelemetryEventEntity +import me.proton.core.user.data.db.AddressDatabase +import me.proton.core.user.data.db.UserConverters +import me.proton.core.user.data.db.UserDatabase +import me.proton.core.user.data.entity.AddressEntity +import me.proton.core.user.data.entity.AddressKeyEntity +import me.proton.core.user.data.entity.UserEntity +import me.proton.core.user.data.entity.UserKeyEntity +import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase +import me.proton.core.userrecovery.data.entity.RecoveryFileEntity +import me.proton.core.usersettings.data.db.OrganizationDatabase +import me.proton.core.usersettings.data.db.UserSettingsConverters +import me.proton.core.usersettings.data.db.UserSettingsDatabase +import me.proton.core.usersettings.data.entity.OrganizationEntity +import me.proton.core.usersettings.data.entity.OrganizationKeysEntity +import me.proton.core.usersettings.data.entity.UserSettingsEntity + +@Database( + entities = [ + // account-data + AccountEntity::class, + AccountMetadataEntity::class, + SessionEntity::class, + SessionDetailsEntity::class, + // auth-data + AuthDeviceEntity::class, + DeviceSecretEntity::class, + MemberDeviceEntity::class, + // user-data + UserEntity::class, + UserKeyEntity::class, + AddressEntity::class, + AddressKeyEntity::class, + // user-recovery + RecoveryFileEntity::class, + // key-data + KeySaltEntity::class, + PublicAddressEntity::class, + PublicAddressKeyEntity::class, + PublicAddressInfoEntity::class, + PublicAddressKeyDataEntity::class, + // human-verification + HumanVerificationEntity::class, + // mail-settings + MailSettingsEntity::class, + // user-settings + UserSettingsEntity::class, + // organization + OrganizationEntity::class, + OrganizationKeysEntity::class, + // contact + ContactEntity::class, + ContactCardEntity::class, + ContactEmailEntity::class, + ContactEmailLabelEntity::class, + // event-manager + EventMetadataEntity::class, + // label + LabelEntity::class, + // feature-flag + FeatureFlagEntity::class, + // challenge + ChallengeFrameEntity::class, + // notification + NotificationEntity::class, + // push + PushEntity::class, + // mail-pagination + PageIntervalEntity::class, + // mail-message + MessageEntity::class, + MessageLabelEntity::class, + MessageBodyEntity::class, + MessageAttachmentEntity::class, + MessageAttachmentMetadataEntity::class, + // mail-conversation + ConversationEntity::class, + ConversationLabelEntity::class, + // in app purchase + GooglePurchaseEntity::class, + PurchaseEntity::class, + // observability + ObservabilityEventEntity::class, + // telemetry + TelemetryEventEntity::class, + // key transparency + AddressChangeEntity::class, + SelfAuditResultEntity::class, + // draft state + DraftStateEntity::class, + AttachmentStateEntity::class, + MessagePasswordEntity::class, + MessageExpirationTimeEntity::class, + // Unread counts + UnreadMessagesCountEntity::class, + UnreadConversationsCountEntity::class, + // Search results + SearchResultEntity::class + ], + version = AppDatabase.version, + exportSchema = true +) +@TypeConverters( + CommonConverters::class, + AccountConverters::class, + UserConverters::class, + CryptoConverters::class, + HumanVerificationConverters::class, + UserSettingsConverters::class, + ContactConverters::class, + EventManagerConverters::class, + LabelConverters::class, + ChallengeConverters::class, + NotificationConverters::class, + PushConverters::class, + MessageConverters::class, + ConversationConverters::class, + MapConverters::class, + AttachmentWorkerStatusConverters::class, + UriConverter::class, + DraftStateConverters::class, + AttachmentStateConverters::class, + AuthConverters::class +) +@Suppress("UnnecessaryAbstractClass") +abstract class AppDatabase : + BaseDatabase(), + AccountDatabase, + UserDatabase, + AddressDatabase, + KeySaltDatabase, + HumanVerificationDatabase, + PublicAddressDatabase, + MailSettingsDatabase, + UserSettingsDatabase, + OrganizationDatabase, + ContactDatabase, + EventMetadataDatabase, + LabelDatabase, + FeatureFlagDatabase, + ChallengeDatabase, + PageIntervalDatabase, + MessageDatabase, + ConversationDatabase, + PaymentDatabase, + ObservabilityDatabase, + KeyTransparencyDatabase, + NotificationDatabase, + PushDatabase, + TelemetryDatabase, + DraftStateDatabase, + SearchResultsDatabase, + DeviceRecoveryDatabase, + AuthDatabase { + + companion object { + + const val name = "db-mail" + const val version = 41 + + internal val migrations = listOf( + AppDatabaseMigrations.MIGRATION_1_2, + AppDatabaseMigrations.MIGRATION_2_3, + AppDatabaseMigrations.MIGRATION_3_4, + AppDatabaseMigrations.MIGRATION_4_5, + AppDatabaseMigrations.MIGRATION_5_6, + AppDatabaseMigrations.MIGRATION_6_7, + AppDatabaseMigrations.MIGRATION_7_8, + AppDatabaseMigrations.MIGRATION_8_9, + AppDatabaseMigrations.MIGRATION_9_10, + AppDatabaseMigrations.MIGRATION_10_11, + AppDatabaseMigrations.MIGRATION_11_12, + AppDatabaseMigrations.MIGRATION_12_13, + AppDatabaseMigrations.MIGRATION_13_14, + AppDatabaseMigrations.MIGRATION_14_15, + AppDatabaseMigrations.MIGRATION_15_16, + AppDatabaseMigrations.MIGRATION_16_17, + AppDatabaseMigrations.MIGRATION_17_18, + AppDatabaseMigrations.MIGRATION_18_19, + AppDatabaseMigrations.MIGRATION_19_20, + AppDatabaseMigrations.MIGRATION_20_21, + AppDatabaseMigrations.MIGRATION_21_22, + AppDatabaseMigrations.MIGRATION_22_23, + AppDatabaseMigrations.MIGRATION_23_24, + AppDatabaseMigrations.MIGRATION_24_25, + AppDatabaseMigrations.MIGRATION_25_26, + AppDatabaseMigrations.MIGRATION_26_27, + AppDatabaseMigrations.MIGRATION_27_28, + AppDatabaseMigrations.MIGRATION_28_29, + AppDatabaseMigrations.MIGRATION_29_30, + AppDatabaseMigrations.MIGRATION_30_31, + AppDatabaseMigrations.MIGRATION_31_32, + AppDatabaseMigrations.MIGRATION_32_33, + AppDatabaseMigrations.MIGRATION_33_34, + AppDatabaseMigrations.MIGRATION_34_35, + AppDatabaseMigrations.MIGRATION_35_36, + AppDatabaseMigrations.MIGRATION_36_37, + AppDatabaseMigrations.MIGRATION_37_38, + AppDatabaseMigrations.MIGRATION_38_39, + AppDatabaseMigrations.MIGRATION_39_40, + AppDatabaseMigrations.MIGRATION_40_41 + ) + + fun buildDatabase(context: Context): AppDatabase = databaseBuilder(context, name) + .apply { migrations.forEach { addMigrations(it) } } + .build() + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt new file mode 100644 index 0000000000..f1fcf96079 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/db/AppDatabaseMigrations.kt @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import ch.protonmail.android.composer.data.local.DraftStateDatabase +import ch.protonmail.android.mailconversation.data.local.ConversationDatabase +import ch.protonmail.android.mailmessage.data.local.MessageDatabase +import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase +import me.proton.core.account.data.db.AccountDatabase +import me.proton.core.auth.data.db.AuthDatabase +import me.proton.core.contact.data.local.db.ContactDatabase +import me.proton.core.eventmanager.data.db.EventMetadataDatabase +import me.proton.core.key.data.db.PublicAddressDatabase +import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase +import me.proton.core.mailsettings.data.db.MailSettingsDatabase +import me.proton.core.notification.data.local.db.NotificationDatabase +import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.push.data.local.db.PushDatabase +import me.proton.core.telemetry.data.db.TelemetryDatabase +import me.proton.core.user.data.db.AddressDatabase +import me.proton.core.user.data.db.UserDatabase +import me.proton.core.user.data.db.UserKeyDatabase +import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase +import me.proton.core.usersettings.data.db.OrganizationDatabase +import me.proton.core.usersettings.data.db.UserSettingsDatabase + +object AppDatabaseMigrations { + + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_0.migrate(db) + } + } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + OrganizationDatabase.MIGRATION_2.migrate(db) + } + } + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + AddressDatabase.MIGRATION_4.migrate(db) + PublicAddressDatabase.MIGRATION_2.migrate(db) + KeyTransparencyDatabase.MIGRATION_0.migrate(db) + } + } + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_1.migrate(db) + } + + } + + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + UserDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + NotificationDatabase.MIGRATION_0.migrate(db) + NotificationDatabase.MIGRATION_1.migrate(db) + PushDatabase.MIGRATION_0.migrate(db) + } + } + + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + UserSettingsDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_3.migrate(db) + } + } + + val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_0.migrate(db) + } + } + + val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + ContactDatabase.MIGRATION_1.migrate(db) + EventMetadataDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_4.migrate(db) + DraftStateDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + UserDatabase.MIGRATION_3.migrate(db) + AccountDatabase.MIGRATION_6.migrate(db) + } + } + + val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + TelemetryDatabase.MIGRATION_0.migrate(db) + UserSettingsDatabase.MIGRATION_3.migrate(db) + } + } + + val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_3.migrate(db) + } + } + + val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_5.migrate(db) + } + + } + + val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + EventMetadataDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + UserSettingsDatabase.MIGRATION_4.migrate(db) + } + } + + val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_4.migrate(db) + } + } + + val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + // Empty migration as UnreadCountDatabase was deleted + // when tables were distributed to message and conversation DBs (migration 23->24) + } + } + + val MIGRATION_22_23 = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_5.migrate(db) + } + } + + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add UnreadMessageCount Table + MessageDatabase.MIGRATION_6.migrate(db) + // Add UnreadConversationsCount Table + ConversationDatabase.MIGRATION_0.migrate(db) + } + } + + + val MIGRATION_24_25 = object : Migration(24, 25) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create SearchResults Table + SearchResultsDatabase.MIGRATION_0.migrate(db) + } + } + + val MIGRATION_25_26 = object : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_6.migrate(db) + } + } + + val MIGRATION_26_27 = object : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_7.migrate(db) + } + } + + val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + UserSettingsDatabase.MIGRATION_5.migrate(db) + UserKeyDatabase.MIGRATION_0.migrate(db) + UserDatabase.MIGRATION_4.migrate(db) + } + } + + val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + DraftStateDatabase.MIGRATION_8.migrate(db) + } + } + + val MIGRATION_29_30 = object : Migration(29, 30) { + override fun migrate(db: SupportSQLiteDatabase) { + MessageDatabase.MIGRATION_7.migrate(db) + } + } + + val MIGRATION_30_31 = object : Migration(30, 31) { + override fun migrate(db: SupportSQLiteDatabase) { + UserDatabase.MIGRATION_5.migrate(db) + AccountDatabase.MIGRATION_7.migrate(db) + } + } + + val MIGRATION_31_32 = object : Migration(31, 32) { + override fun migrate(db: SupportSQLiteDatabase) { + PaymentDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_32_33 = object : Migration(32, 33) { + override fun migrate(db: SupportSQLiteDatabase) { + UserSettingsDatabase.MIGRATION_6.migrate(db) + } + } + + val MIGRATION_33_34 = object : Migration(33, 34) { + override fun migrate(db: SupportSQLiteDatabase) { + DeviceRecoveryDatabase.MIGRATION_0.migrate(db) + DeviceRecoveryDatabase.MIGRATION_1.migrate(db) + UserKeyDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_34_35 = object : Migration(34, 35) { + override fun migrate(db: SupportSQLiteDatabase) { + AccountDatabase.MIGRATION_8.migrate(db) + UserSettingsDatabase.MIGRATION_7.migrate(db) + PublicAddressDatabase.MIGRATION_3.migrate(db) + EventMetadataDatabase.MIGRATION_3.migrate(db) + } + } + + val MIGRATION_35_36 = object : Migration(35, 36) { + override fun migrate(db: SupportSQLiteDatabase) { + ContactDatabase.MIGRATION_2.migrate(db) + } + } + + val MIGRATION_36_37 = object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + AuthDatabase.MIGRATION_0.migrate(db) + AuthDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_37_38 = object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + AuthDatabase.MIGRATION_2.migrate(db) + AuthDatabase.MIGRATION_3.migrate(db) + AuthDatabase.MIGRATION_4.migrate(db) + AuthDatabase.MIGRATION_5.migrate(db) + UserDatabase.MIGRATION_6.migrate(db) + AccountDatabase.MIGRATION_9.migrate(db) + MailSettingsDatabase.MIGRATION_1.migrate(db) + } + } + + val MIGRATION_38_39 = object : Migration(38, 39) { + override fun migrate(db: SupportSQLiteDatabase) { + MailSettingsDatabase.MIGRATION_2.migrate(db) + MailSettingsDatabase.MIGRATION_3.migrate(db) + } + } + + val MIGRATION_39_40 = object : Migration(39, 40) { + override fun migrate(db: SupportSQLiteDatabase) { + UserSettingsDatabase.MIGRATION_8.migrate(db) + } + } + + val MIGRATION_40_41 = object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { + AccountDatabase.MIGRATION_10.migrate(db) + } + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt new file mode 100644 index 0000000000..e4911bec2b --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/AppDatabaseModule.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import android.content.Context +import androidx.room.RoomDatabase +import ch.protonmail.android.composer.data.local.DraftStateDatabase +import ch.protonmail.android.db.AppDatabase +import ch.protonmail.android.mailconversation.data.local.ConversationDatabase +import ch.protonmail.android.mailmessage.data.local.MessageDatabase +import ch.protonmail.android.mailmessage.data.local.SearchResultsDatabase +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import me.proton.core.account.data.db.AccountDatabase +import me.proton.core.auth.data.db.AuthDatabase +import me.proton.core.challenge.data.db.ChallengeDatabase +import me.proton.core.contact.data.local.db.ContactDatabase +import me.proton.core.eventmanager.data.db.EventMetadataDatabase +import me.proton.core.featureflag.data.db.FeatureFlagDatabase +import me.proton.core.humanverification.data.db.HumanVerificationDatabase +import me.proton.core.key.data.db.KeySaltDatabase +import me.proton.core.key.data.db.PublicAddressDatabase +import me.proton.core.keytransparency.data.local.KeyTransparencyDatabase +import me.proton.core.label.data.local.LabelDatabase +import me.proton.core.mailsettings.data.db.MailSettingsDatabase +import me.proton.core.notification.data.local.db.NotificationDatabase +import me.proton.core.observability.data.db.ObservabilityDatabase +import me.proton.core.payment.data.local.db.PaymentDatabase +import me.proton.core.push.data.local.db.PushDatabase +import me.proton.core.telemetry.data.db.TelemetryDatabase +import me.proton.core.user.data.db.AddressDatabase +import me.proton.core.user.data.db.UserDatabase +import me.proton.core.userrecovery.data.db.DeviceRecoveryDatabase +import me.proton.core.usersettings.data.db.OrganizationDatabase +import me.proton.core.usersettings.data.db.UserSettingsDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppDatabaseModule { + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase = AppDatabase.buildDatabase(context) +} + +@Module +@InstallIn(SingletonComponent::class) +@Suppress("TooManyFunctions") +abstract class AppDatabaseBindsModule { + @Binds + abstract fun provideRoomDatabase(appDatabase: AppDatabase): RoomDatabase + + @Binds + abstract fun provideAccountDatabase(appDatabase: AppDatabase): AccountDatabase + + @Binds + abstract fun provideUserDatabase(appDatabase: AppDatabase): UserDatabase + + @Binds + abstract fun provideAddressDatabase(appDatabase: AppDatabase): AddressDatabase + + @Binds + abstract fun provideKeySaltDatabase(appDatabase: AppDatabase): KeySaltDatabase + + @Binds + abstract fun providePublicAddressDatabase(appDatabase: AppDatabase): PublicAddressDatabase + + @Binds + abstract fun provideHumanVerificationDatabase(appDatabase: AppDatabase): HumanVerificationDatabase + + @Binds + abstract fun provideMailSettingsDatabase(appDatabase: AppDatabase): MailSettingsDatabase + + @Binds + abstract fun provideUserSettingsDatabase(appDatabase: AppDatabase): UserSettingsDatabase + + @Binds + abstract fun provideOrganizationDatabase(appDatabase: AppDatabase): OrganizationDatabase + + @Binds + abstract fun provideContactDatabase(appDatabase: AppDatabase): ContactDatabase + + @Binds + abstract fun provideEventMetadataDatabase(appDatabase: AppDatabase): EventMetadataDatabase + + @Binds + abstract fun provideLabelDatabase(appDatabase: AppDatabase): LabelDatabase + + @Binds + abstract fun provideFeatureFlagDatabase(appDatabase: AppDatabase): FeatureFlagDatabase + + @Binds + abstract fun provideChallengeDatabase(appDatabase: AppDatabase): ChallengeDatabase + + @Binds + abstract fun provideMessageDatabase(appDatabase: AppDatabase): MessageDatabase + + @Binds + abstract fun provideConversationDatabase(appDatabase: AppDatabase): ConversationDatabase + + @Binds + abstract fun providePaymentDatabase(appDatabase: AppDatabase): PaymentDatabase + + @Binds + abstract fun provideObservabilityDatabase(appDatabase: AppDatabase): ObservabilityDatabase + + @Binds + abstract fun provideKeyTransparencyDatabase(appDatabase: AppDatabase): KeyTransparencyDatabase + + @Binds + abstract fun provideNotificationDatabase(appDatabase: AppDatabase): NotificationDatabase + + @Binds + abstract fun providePushDatabase(appDatabase: AppDatabase): PushDatabase + + @Binds + abstract fun provideTelemetryDatabase(appDatabase: AppDatabase): TelemetryDatabase + + @Binds + abstract fun provideDraftStateDatabase(appDatabase: AppDatabase): DraftStateDatabase + + @Binds + abstract fun provideSearchResultsDatabase(appDatabase: AppDatabase): SearchResultsDatabase + + @Binds + abstract fun provideDeviceRecoveryDatabase(appDatabase: AppDatabase): DeviceRecoveryDatabase + + @Binds + abstract fun provideAuthDatabase(appDatabase: AppDatabase): AuthDatabase +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt new file mode 100644 index 0000000000..1e364a5c7e --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/ApplicationModule.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import android.content.Context +import androidx.work.WorkManager +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.mailcommon.domain.AppInformation +import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinkHelperImpl +import com.google.android.play.core.review.ReviewManager +import com.google.android.play.core.review.ReviewManagerFactory +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import me.proton.core.account.domain.entity.AccountType +import me.proton.core.compose.theme.AppTheme +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.configuration.EnvironmentConfiguration +import me.proton.core.domain.entity.AppStore +import me.proton.core.domain.entity.Product +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ApplicationModule { + + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class LocalDiskOpCoroutineScope + + @Provides + @Singleton + fun provideProduct(): Product = Product.Mail + + @Provides + @Singleton + fun provideAppStore() = AppStore.GooglePlay + + @Provides + fun provideAppTheme() = AppTheme { content -> + ProtonTheme { content() } + } + + @Provides + @Singleton + fun provideAppInfo(envConfig: EnvironmentConfiguration): AppInformation = AppInformation( + appName = "Proton Mail", + appVersionName = BuildConfig.VERSION_NAME, + appVersionCode = BuildConfig.VERSION_CODE, + appBuildType = BuildConfig.BUILD_TYPE, + appBuildFlavor = BuildConfig.FLAVOR, + appHost = envConfig.host + ) + + @Provides + @Singleton + fun provideRequiredAccountType(): AccountType = AccountType.Internal + + @Provides + @Singleton + fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context) + + @Provides + @Singleton + fun provideReviewManager(@ApplicationContext context: Context): ReviewManager = ReviewManagerFactory.create(context) + + @Module + @InstallIn(SingletonComponent::class) + interface BindsModule { + + @Binds + fun bindNotificationsDeepLinkHelper(impl: NotificationsDeepLinkHelperImpl): NotificationsDeepLinkHelper + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt new file mode 100644 index 0000000000..3e9bc3ab0c --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/AuthModule.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.auth.domain.usecase.PostLoginAccountSetup +import me.proton.core.auth.presentation.DefaultHelpOptionHandler +import me.proton.core.auth.presentation.DefaultUserCheck +import me.proton.core.auth.presentation.HelpOptionHandler +import me.proton.core.user.domain.UserManager +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AuthModule { + @Provides + @Singleton + fun provideUserCheck( + @ApplicationContext context: Context, + accountManager: AccountManager, + userManager: UserManager + ): PostLoginAccountSetup.UserCheck = DefaultUserCheck( + context, + accountManager, + userManager + ) + + @Provides + @Singleton + fun provideHelpOptionHandler(): HelpOptionHandler = DefaultHelpOptionHandler() +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt new file mode 100644 index 0000000000..59ccdf90b8 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/AutoLockModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.mailsettings.domain.handler.ForegroundAwareAutoLockHandler +import dagger.Module +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AutoLockModule { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface EntryPointModule { + + fun autoLockHandler(): ForegroundAwareAutoLockHandler + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt new file mode 100644 index 0000000000..289a2d21ec --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/BenchmarkModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracer +import ch.protonmail.android.mailcommon.domain.benchmark.BenchmarkTracerImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object BenchmarkModule { + + @Provides + @Singleton + fun provideBenchmarkTracer(@BuildType buildType: String): BenchmarkTracer = + BenchmarkTracerImpl(buildType == "benchmark") +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt new file mode 100644 index 0000000000..789a3b5a40 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/BuildConfigModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier + +@Module +@InstallIn(SingletonComponent::class) +object BuildConfigModule { + + @Provides + @BuildFlavor + fun provideBuildFlavor() = BuildConfig.FLAVOR + + @Provides + @BuildDebug + fun provideBuildDebug() = BuildConfig.DEBUG + + @Provides + @BuildType + fun provideBuildType() = BuildConfig.BUILD_TYPE +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BuildFlavor + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BuildDebug + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BuildType diff --git a/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt new file mode 100644 index 0000000000..704b1c32a0 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/EventManagerModule.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.mailconversation.data.ConversationEventListener +import ch.protonmail.android.mailconversation.data.UnreadConversationsCountEventListener +import ch.protonmail.android.mailmessage.data.UnreadMessagesCountEventListener +import ch.protonmail.android.mailmessage.data.MessageEventListener +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ElementsIntoSet +import me.proton.core.contact.data.ContactEmailEventListener +import me.proton.core.contact.data.ContactEventListener +import me.proton.core.eventmanager.data.EventManagerQueryMapProvider +import me.proton.core.eventmanager.domain.EventListener +import me.proton.core.label.data.LabelEventListener +import me.proton.core.mailsettings.data.MailSettingsEventListener +import me.proton.core.notification.data.NotificationEventListener +import me.proton.core.push.data.PushEventListener +import me.proton.core.user.data.UserAddressEventListener +import me.proton.core.user.data.UserEventListener +import me.proton.core.user.data.UserSpaceEventListener +import me.proton.core.usersettings.data.UserSettingsEventListener +import javax.inject.Singleton + +@Module(includes = [EventManagerModule.BindersModule::class]) +@InstallIn(SingletonComponent::class) +@Suppress("LongParameterList") +object EventManagerModule { + + @Provides + @Singleton + @ElementsIntoSet + @JvmSuppressWildcards + fun provideEventListenerSet( + userEventListener: UserEventListener, + userAddressEventListener: UserAddressEventListener, + userSettingsEventListener: UserSettingsEventListener, + mailSettingsEventListener: MailSettingsEventListener, + contactEventListener: ContactEventListener, + contactEmailEventListener: ContactEmailEventListener, + labelEventListener: LabelEventListener, + messageEventListener: MessageEventListener, + conversationEventListener: ConversationEventListener, + notificationEventListener: NotificationEventListener, + pushEventListener: PushEventListener, + unreadMessagesCountEventListener: UnreadMessagesCountEventListener, + unreadConversationsCountEventListener: UnreadConversationsCountEventListener, + userSpaceEventListener: UserSpaceEventListener + ): Set> = setOf( + userEventListener, + userAddressEventListener, + userSettingsEventListener, + userSpaceEventListener, + mailSettingsEventListener, + contactEventListener, + contactEmailEventListener, + labelEventListener, + messageEventListener, + conversationEventListener, + notificationEventListener, + pushEventListener, + unreadMessagesCountEventListener, + unreadConversationsCountEventListener + ) + + @Module + @InstallIn(SingletonComponent::class) + internal interface BindersModule { + + @Binds + @Singleton + fun bindsEventManagerQueryMapProvider(impl: MailEventManagerQueryMapProvider): EventManagerQueryMapProvider + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt new file mode 100644 index 0000000000..49b1253cc1 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/FeatureFlagModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object FeatureFlagModule { + + @Provides + @Singleton + fun provideDefaultMailFeatureFlags(): MailFeatureDefaults { + return MailFeatureDefaults( + mapOf( + MailFeatureId.ConversationMode to true, + MailFeatureId.RatingBooster to false + ) + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt new file mode 100644 index 0000000000..69813c6942 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/HumanVerificationModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.configuration.EnvironmentConfiguration +import me.proton.core.humanverification.presentation.HumanVerificationApiHost +import me.proton.core.humanverification.presentation.utils.HumanVerificationVersion + +@Module +@InstallIn(SingletonComponent::class) +object HumanVerificationModule { + + @Provides + @HumanVerificationApiHost + fun provideHumanVerificationApiHost(envConfig: EnvironmentConfiguration): String = "https://${envConfig.hv3Host}/" + + @Provides + fun provideHumanVerificationVersion() = HumanVerificationVersion.HV3 +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt b/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt new file mode 100644 index 0000000000..c1b8bafd76 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/MailEventManagerQueryMapProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import me.proton.core.eventmanager.data.EventManagerQueryMapProvider +import me.proton.core.eventmanager.domain.EventManagerConfig +import javax.inject.Inject + +class MailEventManagerQueryMapProvider @Inject constructor() : EventManagerQueryMapProvider { + + override suspend fun getQueryMap(config: EventManagerConfig): Map = when (config) { + is EventManagerConfig.Core -> mapOf("MessageCounts" to "1", "ConversationCounts" to "1") + else -> emptyMap() + } + +} diff --git a/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt b/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt new file mode 100644 index 0000000000..0e4d486caf --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/di/NetworkModule.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.di.ApplicationModule.LocalDiskOpCoroutineScope +import ch.protonmail.android.feature.alternativerouting.HasAlternativeRouting +import ch.protonmail.android.feature.forceupdate.ForceUpdateHandler +import ch.protonmail.android.useragent.BuildUserAgent +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import me.proton.core.configuration.EnvironmentConfiguration +import me.proton.core.network.data.client.ExtraHeaderProviderImpl +import me.proton.core.network.data.di.AlternativeApiPins +import me.proton.core.network.data.di.BaseProtonApiUrl +import me.proton.core.network.data.di.CertificatePins +import me.proton.core.network.data.di.Constants +import me.proton.core.network.data.di.DohProviderUrls +import me.proton.core.network.domain.ApiClient +import me.proton.core.network.domain.client.ExtraHeaderProvider +import me.proton.core.network.domain.serverconnection.DohAlternativesListener +import me.proton.core.util.kotlin.takeIfNotBlank +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +@Suppress("LongParameterList") +object NetworkModule { + @Provides + @Singleton + fun provideApiClient( + buildUserAgent: BuildUserAgent, + forceUpdateHandler: ForceUpdateHandler, + hasAlternativeRouting: HasAlternativeRouting + ) = object : ApiClient { + override val appVersionHeader: String + get() = "android-mail@${BuildConfig.VERSION_NAME}" + override val enableDebugLogging: Boolean + get() = BuildConfig.DEBUG + override val shouldUseDoh: Boolean + get() = hasAlternativeRouting().value.isEnabled + override val userAgent: String + get() = buildUserAgent() + + override fun forceUpdate(errorMessage: String) { + forceUpdateHandler.onForceUpdate(errorMessage) + } + } + + @Provides + @Singleton + @LocalDiskOpCoroutineScope + fun provideLocalDiskOpCoroutineScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Provides + @Singleton + fun provideExtraHeaderProvider(): ExtraHeaderProvider = ExtraHeaderProviderImpl().apply { + val proxyToken: String? = BuildConfig.PROXY_TOKEN + proxyToken?.takeIfNotBlank()?.let { addHeaders("X-atlas-secret" to it) } + } + + @DohProviderUrls + @Provides + fun provideDohProviderUrls(): Array = Constants.DOH_PROVIDERS_URLS + + @CertificatePins + @Provides + fun provideCertificatePins(): Array = + Constants.DEFAULT_SPKI_PINS.takeIf { BuildConfig.USE_DEFAULT_PINS } ?: emptyArray() + + @AlternativeApiPins + @Provides + fun provideAlternativeApiPins(): List = + Constants.ALTERNATIVE_API_SPKI_PINS.takeIf { BuildConfig.USE_DEFAULT_PINS } ?: emptyList() + + @Provides + @Singleton + fun provideDohAlternativesListener(): DohAlternativesListener? = null +} + +@Module +@InstallIn(SingletonComponent::class) +object NetworkConfigModule { + + @Provides + @BaseProtonApiUrl + fun provideProtonApiUrl(envConfig: EnvironmentConfiguration): HttpUrl = envConfig.baseUrl.toHttpUrl() +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt new file mode 100644 index 0000000000..0d7dad287a --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/RemoveAccountDialog.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.account + +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.R +import ch.protonmail.android.feature.account.SignOutAccountViewModel.State +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogText +import me.proton.core.compose.component.ProtonTextButton +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultStrongNorm +import me.proton.core.domain.entity.UserId + +@Composable +fun RemoveAccountDialog( + modifier: Modifier = Modifier, + userId: UserId? = null, + onCancelled: () -> Unit, + onRemoved: () -> Unit, + viewModel: SignOutAccountViewModel = hiltViewModel() +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + + when (viewState) { + State.Removed -> onRemoved() + else -> Unit + } + + RemoveAccountDialog( + modifier = modifier, + viewState = viewState, + onCancelClicked = onCancelled, + onRemoveClicked = { viewModel.signOut(userId, removeAccount = true) } + ) +} + +@Composable +private fun RemoveAccountDialog( + modifier: Modifier = Modifier, + viewState: State, + onCancelClicked: () -> Unit, + onRemoveClicked: () -> Unit +) { + ProtonAlertDialog( + modifier = modifier, + onDismissRequest = onCancelClicked, + title = stringResource(id = R.string.dialog_remove_account_title), + text = { ProtonAlertDialogText(text = stringResource(id = R.string.dialog_remove_account_description)) }, + confirmButton = { + ProtonTextButton( + onClick = onRemoveClicked, + content = { + when (viewState) { + State.Initial, + State.Removed -> Text( + text = stringResource(id = R.string.dialog_remove_account_confirm), + style = ProtonTheme.typography.defaultStrongNorm, + color = ProtonTheme.colors.textAccent + ) + + State.Removing -> CircularProgressIndicator() + else -> Unit + } + } + ) + }, + dismissButton = { + ProtonTextButton(onClick = onCancelClicked) { + Text( + text = stringResource(id = R.string.dialog_remove_account_cancel), + style = ProtonTheme.typography.defaultStrongNorm, + color = ProtonTheme.colors.textAccent + ) + } + } + ) +} + +object RemoveAccountDialog { + + const val USER_ID_KEY = "user id" +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt new file mode 100644 index 0000000000..26312e064e --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountDialog.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.account + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.feature.account.SignOutAccountViewModel.State +import me.proton.core.accountmanager.presentation.compose.SignOutDialog +import me.proton.core.domain.entity.UserId + +@Composable +fun SignOutAccountDialog( + modifier: Modifier = Modifier, + userId: UserId? = null, + actions: SignOutAccountDialog.Actions, + viewModel: SignOutAccountViewModel = hiltViewModel() +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + + when (viewState) { + State.SignedOut -> actions.onSignedOut() + State.Removed -> actions.onRemoved() + else -> Unit + } + + SignOutDialog( + modifier = modifier, + onDismiss = actions.onCancelled, + onDisableAccount = { viewModel.signOut(userId, removeAccount = false) }, + onRemoveAccount = { viewModel.signOut(userId, removeAccount = true) } + ) +} + +object SignOutAccountDialog { + + const val USER_ID_KEY = "user id" + + data class Actions( + val onSignedOut: () -> Unit, + val onRemoved: () -> Unit, + val onCancelled: () -> Unit + ) +} + +object SignOutAccountDialogTestTags { + + const val RootItem = "SignOutAccountDialogRootItem" + const val YesButton = "YesButton" + const val NoButton = "NoButton" +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt new file mode 100644 index 0000000000..06ebf79b0d --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModel.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +@HiltViewModel +class SignOutAccountViewModel @Inject constructor( + private val accountManager: AccountManager, + private val enqueuer: Enqueuer +) : ViewModel() { + + private val mutableState = MutableStateFlow(State.Initial) + val state = mutableState.asStateFlow() + + fun signOut(userId: UserId? = null, removeAccount: Boolean = false) = viewModelScope.launch { + val resolvedUserId = requireNotNull(userId ?: getPrimaryUserIdOrNull()) + enqueuer.cancelAllWork(resolvedUserId) + + if (removeAccount) { + mutableState.emit(State.Removing) + accountManager.removeAccount(resolvedUserId) + mutableState.emit(State.Removed) + } else { + mutableState.emit(State.SigningOut) + accountManager.disableAccount(resolvedUserId) + mutableState.emit(State.SignedOut) + } + } + + private suspend fun getPrimaryUserIdOrNull() = accountManager.getPrimaryUserId().firstOrNull() + + sealed class State { + object Initial : State() + object SigningOut : State() + object SignedOut : State() + object Removing : State() + object Removed : State() + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt b/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt new file mode 100644 index 0000000000..0d6688d6e6 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRouting.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.alternativerouting + +import ch.protonmail.android.di.ApplicationModule.LocalDiskOpCoroutineScope +import ch.protonmail.android.mailsettings.domain.model.AlternativeRoutingPreference +import ch.protonmail.android.mailsettings.domain.repository.AlternativeRoutingRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +class HasAlternativeRouting @Inject constructor( + private val alternativeRoutingRepository: AlternativeRoutingRepository, + @LocalDiskOpCoroutineScope + private val coroutineScope: CoroutineScope +) { + + private val initialValue = AlternativeRoutingPreference(true) + + operator fun invoke() = alternativeRoutingRepository.observe() + .map { alternativeRoutingPreferenceEither -> + alternativeRoutingPreferenceEither.fold( + ifLeft = { initialValue }, + ifRight = { it } + ) + } + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = initialValue + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt b/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt new file mode 100644 index 0000000000..79cce6d72a --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/forceupdate/ForceUpdateHandler.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.forceupdate + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.core.presentation.app.AppLifecycleObserver +import me.proton.core.presentation.app.AppLifecycleProvider +import me.proton.core.presentation.ui.alert.ForceUpdateActivity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ForceUpdateHandler @Inject constructor( + @ApplicationContext + private val context: Context, + private val appLifecycleObserver: AppLifecycleObserver +) { + fun onForceUpdate(errorMessage: String) { + if (appLifecycleObserver.state.value == AppLifecycleProvider.State.Foreground) { + startForceUpdateActivity(errorMessage) + } + } + + private fun startForceUpdateActivity(errorMessage: String) { + context.startActivity(ForceUpdateActivity(context, errorMessage)) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt new file mode 100644 index 0000000000..7e38955572 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscription.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.postsubscription + +import java.lang.ref.WeakReference +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import ch.protonmail.android.PostSubscriptionActivity +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser +import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState +import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState.UserUpgradeCheckState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import me.proton.core.user.domain.extension.hasSubscriptionForMail +import javax.inject.Inject + +class ObservePostSubscription @Inject constructor( + private val observePostSubscriptionFlowEnabled: ObservePostSubscriptionFlowEnabled, + private val observePrimaryUser: ObservePrimaryUser, + private val userUpgradeState: UserUpgradeState +) { + + suspend fun start(activity: AppCompatActivity) { + val activityReference = WeakReference(activity) + var startedPendingPurchase = false + observePrimaryUser() + .filterNotNull() + .map { it to it.hasSubscriptionForMail() } + .distinctUntilChangedBy { it.second } + .collectLatest { (user, hasSubscription) -> + if (hasSubscription) { + if (startedPendingPurchase) { + startedPendingPurchase = false + activityReference.showPostSubscription() + } + return@collectLatest + } + observePostSubscriptionFlowEnabled(user.userId) + .filter { it?.value == true } + .distinctUntilChanged() + .collectLatest innerCollector@{ + userUpgradeState.userUpgradeCheckState.awaitFlowStarted() ?: return@innerCollector + startedPendingPurchase = true + val upgradeState = userUpgradeState.userUpgradeCheckState.awaitFlowComplete() + startedPendingPurchase = false + if (upgradeState == null) return@innerCollector + if (upgradeState.upgradedPlanNames.contains(MAIL_PLUS_PLAN_NAME)) { + activityReference.showPostSubscription() + } + } + } + } + + private fun WeakReference.showPostSubscription() { + get()?.let { activity -> + activity.startActivity(Intent(activity, PostSubscriptionActivity::class.java)) + } + } + + private suspend fun Flow.awaitFlowStarted() = filter { + it == UserUpgradeCheckState.Pending + }.firstOrNull() + + private suspend fun Flow.awaitFlowComplete() = filter { + it is UserUpgradeCheckState.CompletedWithUpgrade + }.firstOrNull() as? UserUpgradeCheckState.CompletedWithUpgrade +} + +private const val MAIL_PLUS_PLAN_NAME = "mail2022" diff --git a/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt new file mode 100644 index 0000000000..f7a34db02c --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionFlowEnabled.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.postsubscription + +import me.proton.core.domain.entity.UserId +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureId +import javax.inject.Inject + +class ObservePostSubscriptionFlowEnabled @Inject constructor( + private val featureFlagManager: FeatureFlagManager +) { + + operator fun invoke(userId: UserId?) = featureFlagManager.observe(userId, FeatureId(FeatureFlagId)) + + private companion object { + + const val FeatureFlagId = "MailAndroidPostSubscription" + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt new file mode 100644 index 0000000000..04652285f4 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AccountStateHandlerInitializer.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import me.proton.core.accountmanager.data.AccountStateHandler + +class AccountStateHandlerInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + AccountStateHandlerInitializerEntryPoint::class.java + ).accountStateHandler().start() + } + + override fun dependencies(): List?>> = listOf( + LoggerInitializer::class.java + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AccountStateHandlerInitializerEntryPoint { + fun accountStateHandler(): AccountStateHandler + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt new file mode 100644 index 0000000000..cf91895df1 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AppInBackgroundCheckerInitializer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.startup.Initializer +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class AppInBackgroundCheckerInitializer : Initializer, LifecycleEventObserver { + + private var appInBackgroundState: AppInBackgroundState? = null + override fun create(context: Context) { + appInBackgroundState = EntryPointAccessors.fromApplication( + context.applicationContext, + AppInBackgroundCheckerInitializerEntryPoint::class.java + ).appInBackgroundState() + + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + override fun dependencies(): List>> = emptyList() + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_RESUME -> appInBackgroundState?.setAppInBackground(false) + Lifecycle.Event.ON_PAUSE -> appInBackgroundState?.setAppInBackground(true) + else -> Unit + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface AppInBackgroundCheckerInitializerEntryPoint { + + fun appInBackgroundState(): AppInBackgroundState + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt new file mode 100644 index 0000000000..2636c4f60e --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/AutoLockHandlerInitializer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.di.AutoLockModule +import dagger.hilt.android.EntryPointAccessors + +internal class AutoLockHandlerInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + AutoLockModule.EntryPointModule::class.java + ).autoLockHandler().handle() + } + + override fun dependencies(): List>> = listOf( + AppInBackgroundCheckerInitializer::class.java + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt new file mode 100644 index 0000000000..e15147f724 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/EventManagerInitializer.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import me.proton.core.eventmanager.data.CoreEventManagerStarter + +class EventManagerInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + EventManagerInitializerEntryPoint::class.java + ).starter().start() + } + + override fun dependencies(): List?>> = listOf( + LoggerInitializer::class.java, + WorkManagerInitializer::class.java + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface EventManagerInitializerEntryPoint { + fun starter(): CoreEventManagerStarter + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt new file mode 100644 index 0000000000..9e5642fdb0 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/FeatureFlagInitializer.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Proton AG + * This file is part of Proton AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.initializer.featureflag.RefreshRatingBoosterFeatureFlags +import me.proton.core.featureflag.data.FeatureFlagRefreshStarter + +class FeatureFlagInitializer : Initializer { + + override fun create(context: Context) { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + FeatureFlagInitializerEntryPoint::class.java + ) + entryPoint.featureFlagRefreshStarter().start(BuildConfig.DEBUG) + entryPoint.refreshRatingBoosterFeatureFlags().invoke() + } + + override fun dependencies(): List?>> = listOf( + WorkManagerInitializer::class.java + ) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface FeatureFlagInitializerEntryPoint { + fun featureFlagRefreshStarter(): FeatureFlagRefreshStarter + + fun refreshRatingBoosterFeatureFlags(): RefreshRatingBoosterFeatureFlags + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt new file mode 100644 index 0000000000..ae74fcd8d3 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/LoggerInitializer.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.mailbugreport.data.FileLoggingTree +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import me.proton.core.util.android.sentry.TimberLogger +import me.proton.core.util.kotlin.CoreLogger +import timber.log.Timber +import javax.inject.Provider + +class LoggerInitializer : Initializer { + + override fun create(context: Context) { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + + // Forward Core Logs to Timber, using TimberLogger. + CoreLogger.set(TimberLogger) + + val accessors = EntryPointAccessors.fromApplication( + context.applicationContext, + LoggerInitializerEntryPoint::class.java + ) + + val isLoggingEnabled = accessors.logsExportFeatureSetting().get().isEnabled + if (isLoggingEnabled.not()) return + + val logsFileHandler = accessors.logsFileHandlerProvider() + Timber.plant(FileLoggingTree(logsFileHandler)) + } + + override fun dependencies(): List>> = emptyList() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface LoggerInitializerEntryPoint { + + fun logsFileHandlerProvider(): LogsFileHandler + + @LogsExportFeatureSettingValue + fun logsExportFeatureSetting(): Provider + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt new file mode 100644 index 0000000000..2801353fd6 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/MainInitializer.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.initializer.strictmode.StrictModeInitializer +import ch.protonmail.android.mailupselling.domain.initializers.UpgradeStateInitializer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import me.proton.core.auth.presentation.MissingScopeInitializer +import me.proton.core.crypto.validator.presentation.init.CryptoValidatorInitializer +import me.proton.core.humanverification.presentation.HumanVerificationInitializer +import me.proton.core.network.presentation.init.UnAuthSessionFetcherInitializer +import me.proton.core.paymentiap.presentation.GooglePurchaseHandlerInitializer +import me.proton.core.plan.presentation.PurchaseHandlerInitializer +import me.proton.core.plan.presentation.UnredeemedPurchaseInitializer +import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryInitializer + +class MainInitializer : Initializer { + + // create a nested class to initialise some of the non-essential time consuming dependencies in a background thread + class MainAsyncInitializer : Initializer { + override fun create(context: Context) { + // No-op needed + } + + override fun dependencies() = coreDependencies() + mailDependencies() + releaseOnlyDependenciesIfNeeded() + + private fun coreDependencies() = listOf( + FeatureFlagInitializer::class.java + ) + + private fun mailDependencies(): List?>> = emptyList() + + private fun releaseOnlyDependenciesIfNeeded() = + if (BuildConfig.DEBUG) emptyList() else listOf(SentryInitializer::class.java) + } + + override fun create(context: Context) { + // No-op needed + } + + override fun dependencies() = coreDependencies() + mailDependencies() + + private fun coreDependencies() = listOf( + CryptoValidatorInitializer::class.java, + DeviceRecoveryInitializer::class.java, + PurchaseHandlerInitializer::class.java, + GooglePurchaseHandlerInitializer::class.java, + HumanVerificationInitializer::class.java, + MissingScopeInitializer::class.java, + UnredeemedPurchaseInitializer::class.java, + UnAuthSessionFetcherInitializer::class.java + ) + + private fun mailDependencies() = listOf( + AccountStateHandlerInitializer::class.java, + UpgradeStateInitializer::class.java, + EventManagerInitializer::class.java, + LoggerInitializer::class.java, + StrictModeInitializer::class.java, + ThemeObserverInitializer::class.java, + NotificationInitializer::class.java, + NotificationHandlersInitializer::class.java, + OutboxInitializer::class.java, + AutoLockHandlerInitializer::class.java + ) + + companion object { + + fun init(appContext: Context) { + with(AppInitializer.getInstance(appContext)) { + // WorkManager need to be initialized before any other dependant initializer. + initializeComponent(WorkManagerInitializer::class.java) + initializeComponent(MainInitializer::class.java) + } + + // Initialize some non-essential initializers in a background thread. They are taking most + // time to initialize. This line must be after initialisation of MainInitializer above, because + // AppInitializer has an internal lock, which prevents simultaneous initialisation + CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { + AppInitializer.getInstance(appContext).initializeComponent(MainAsyncInitializer::class.java) + } + + } + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt new file mode 100644 index 0000000000..1260d7a5d3 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationHandlersInitializer.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.mailnotifications.dagger.MailNotificationsModule +import dagger.hilt.android.EntryPointAccessors + +internal class NotificationHandlersInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + MailNotificationsModule.EntryPointModule::class.java + ).handlers().forEach { it.handle() } + } + + override fun dependencies(): List>> = listOf( + AccountStateHandlerInitializer::class.java, + AppInBackgroundCheckerInitializer::class.java + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt new file mode 100644 index 0000000000..85405ccd7a --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/NotificationInitializer.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.mailcommon.presentation.system.NotificationProvider +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class NotificationInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + NotificationInitializerEntryPoint::class.java + ).notificationProvider().initNotificationChannels() + } + + override fun dependencies(): List>> = listOf(AppInBackgroundCheckerInitializer::class.java) + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface NotificationInitializerEntryPoint { + + fun notificationProvider(): NotificationProvider + } + +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt new file mode 100644 index 0000000000..c47ec28f49 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/OutboxInitializer.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.initializer.outbox.OutboxObserver +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class OutboxInitializer : Initializer { + + override fun create(context: Context) { + + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + OutboxInitializerEntryPoint::class.java + ) + entryPoint.outboxObserver().start() + + } + + override fun dependencies(): List>> = emptyList() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface OutboxInitializerEntryPoint { + fun outboxObserver(): OutboxObserver + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt new file mode 100644 index 0000000000..a98927b280 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/SentryInitializer.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.logging.SentryUserObserver +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroid +import me.proton.core.configuration.EnvironmentConfigurationDefaults +import me.proton.core.util.android.sentry.TimberLoggerIntegration +import me.proton.core.util.android.sentry.project.AccountSentryHubBuilder + +class SentryInitializer : Initializer { + + override fun create(context: Context) { + SentryAndroid.init(context.applicationContext) { options: SentryOptions -> + options.dsn = BuildConfig.SENTRY_DSN + options.release = BuildConfig.VERSION_NAME + options.environment = EnvironmentConfigurationDefaults.host + options.addIntegration( + TimberLoggerIntegration( + minEventLevel = SentryLevel.WARNING, + minBreadcrumbLevel = SentryLevel.INFO + ) + ) + } + + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + SentryInitializerEntryPoint::class.java + ) + entryPoint.observer().start() + + entryPoint.accountSentryHubBuilder().invoke( + sentryDsn = BuildConfig.ACCOUNT_SENTRY_DSN + ) { options -> + options.isEnableUncaughtExceptionHandler = false // MAILANDR-2602 + } + } + + override fun dependencies(): List>> = emptyList() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SentryInitializerEntryPoint { + fun accountSentryHubBuilder(): AccountSentryHubBuilder + fun observer(): SentryUserObserver + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt new file mode 100644 index 0000000000..91cfdcf3dd --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/ThemeObserverInitializer.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeObserver +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class ThemeObserverInitializer : Initializer { + + override fun create(context: Context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + ThemeObserverInitializerEntryPoint::class.java + ).themeObserver().start() + } + + override fun dependencies(): List?>> = emptyList() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ThemeObserverInitializerEntryPoint { + fun themeObserver(): ThemeObserver + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt new file mode 100644 index 0000000000..0315bed67f --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/WorkManagerInitializer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer + +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import androidx.startup.Initializer +import androidx.work.Configuration +import androidx.work.WorkManager +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +class WorkManagerInitializer : Initializer { + + override fun create(context: Context): WorkManager { + val workerFactory = EntryPointAccessors.fromApplication( + context.applicationContext, + WorkManagerInitializerEntryPoint::class.java + ).hiltWorkerFactory() + val config = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + WorkManager.initialize(context, config) + return WorkManager.getInstance(context) + } + + override fun dependencies(): List?>> = emptyList() + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface WorkManagerInitializerEntryPoint { + fun hiltWorkerFactory(): HiltWorkerFactory + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt new file mode 100644 index 0000000000..87bb5e0f30 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlags.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer.featureflag + +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureFlag +import me.proton.core.util.kotlin.CoroutineScopeProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RefreshRatingBoosterFeatureFlags @Inject constructor( + private val accountManager: AccountManager, + private val scopeProvider: CoroutineScopeProvider, + private val featureFlagManager: FeatureFlagManager +) { + + operator fun invoke() { + scopeProvider.GlobalIOSupervisedScope.launch { + accountManager.getAccounts().first().forEach { account -> + refreshRatingBoosterFeatureFlag(account.userId) + } + } + } + + private suspend fun refreshRatingBoosterFeatureFlag(userId: UserId) { + featureFlagManager.getOrDefault( + userId = userId, + featureId = MailFeatureId.RatingBooster.id, + default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false), + refresh = true + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt new file mode 100644 index 0000000000..a7b195da0a --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/outbox/OutboxObserver.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer.outbox + +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailmessage.data.usecase.DeleteSentMessagesFromOutbox +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.OutboxRepository +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.util.kotlin.CoroutineScopeProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OutboxObserver @Inject constructor( + private val scopeProvider: CoroutineScopeProvider, + private val accountManager: AccountManager, + private val outboxRepository: OutboxRepository, + private val deleteSentMessagesFromOutbox: DeleteSentMessagesFromOutbox, + private val draftUploadTracker: DraftUploadTracker +) { + + fun start() = accountManager.getPrimaryUserId() + .filterNotNull() + .flatMapLatest { userId -> + outboxRepository.observeAll(userId) + } + .onEach { outboxDraftStates -> + + val sentItems = outboxDraftStates.filter { draftState -> draftState.state == DraftSyncState.Sent } + if (sentItems.isNotEmpty()) { + draftUploadTracker.notifySentMessages(sentItems.map { it.messageId }.toSet()) + + deleteSentMessagesFromOutbox( + sentItems.first().userId, + sentItems + ) + } + } + .launchIn(scopeProvider.GlobalIOSupervisedScope) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt new file mode 100644 index 0000000000..994f594aaa --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeHackArrayList.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer.strictmode + +import timber.log.Timber +import java.lang.reflect.Method + +/** + * Special array list that skip additions for matching ViolationInfo instances as per + * hack described in https://atscaleconference.com/videos/eliminating-long-tail-jank-with-strictmode/ + */ +class StrictModeHackArrayList : ArrayList() { + + private val whitelistedViolations = listOf( + // Violations observed only in Firebase tests + "android.graphics.HwTypefaceUtil.getMultiWeightHwFamily", + "android.graphics.HwTypefaceUtil.updateFont", + // AppLanguageRepository reading locale from file through + // AppCompatDelegate (due to `autoStoreLocales` manifest metadata) + "androidx.appcompat.app.AppLocalesStorageHelper.readLocales", + // Firebase tests initialization + "androidx.test.runner.MonitoringInstrumentation.specifyDexMakerCacheProperty", + // Reading from file + "ch.protonmail.android.initializer.SentryInitializer.create", + // Reading from SharedPreferences + "me.proton.core.util.android.sharedpreferences.ExtensionsKt.nullableGet" + ) + + override fun add(element: Any): Boolean { + val hasDeclaredMethod = element.javaClass.declaredMethods.any { it.name == "getStackTrace" } + if (!hasDeclaredMethod) { + // call super to continue with standard violation reporting + return super.add(element) + } + + val crashInfoMethod: Method = element.javaClass.getDeclaredMethod("getStackTrace") + crashInfoMethod.invoke(element)?.let { crashInfoStackTrace -> + for (whitelistedStacktraceCall in whitelistedViolations) { + if (crashInfoStackTrace.toString().contains(whitelistedStacktraceCall)) { + Timber.d("Skipping whitelisted StrictMode violation: $whitelistedStacktraceCall") + return false + } + } + } + // call super to continue with standard violation reporting + return super.add(element) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt new file mode 100644 index 0000000000..01c09a0dea --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/initializer/strictmode/StrictModeInitializer.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.initializer.strictmode + +import android.content.Context +import android.os.StrictMode +import androidx.startup.Initializer +import ch.protonmail.android.BuildConfig +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import me.proton.core.util.android.strictmode.detectCommon + +class StrictModeInitializer : Initializer { + + override fun create(context: Context) { + if (BuildConfig.DEBUG) { + enableStrictMode() + } + } + + override fun dependencies(): List>> = emptyList() + + private fun enableStrictMode() { + val threadPolicyBuilder = StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyFlashScreen() + .penaltyLog() + val vmPolicyBuilder = StrictMode.VmPolicy.Builder() + .detectCommon() + .penaltyLog() + + StrictMode.setThreadPolicy(threadPolicyBuilder.build()) + StrictMode.setVmPolicy(vmPolicyBuilder.build()) + ignoreWhitelistedWarnings() + } + + private fun ignoreWhitelistedWarnings() { + // Source: https://atscaleconference.com/videos/eliminating-long-tail-jank-with-strictmode/ + // On API levels above N, we can use reflection to read the violationsBeingTimed field of strict + // to get notifications about reported violations + val field = StrictMode::class.java.getDeclaredField("violationsBeingTimed") + field.isAccessible = true // Suppress Java language access checking + // Remove "final" modifier + val modifiersField = Field::class.java.getDeclaredField("accessFlags") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + // Override the field with a custom ArrayList, which is able to skip whitelisted violations + field.set( + null, + object : ThreadLocal>() { + override fun get(): ArrayList = StrictModeHackArrayList() + } + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt b/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt new file mode 100644 index 0000000000..9b0de7a0e5 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/logging/LogsFileHandlerLifecycleObserver.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.logging + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import ch.protonmail.android.initializer.LoggerInitializer +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import dagger.hilt.android.EntryPointAccessors + +/** + * A [LifecycleObserver] used to clean up the [LogsFileHandler] instance. + */ +internal class LogsFileHandlerLifecycleObserver( + context: Context +) : DefaultLifecycleObserver { + + private val logsFileHandler: LogsFileHandler + + init { + val entryPoint = EntryPointAccessors.fromApplication( + context, LoggerInitializer.LoggerInitializerEntryPoint::class.java + ) + logsFileHandler = entryPoint.logsFileHandlerProvider() + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + logsFileHandler.close() + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt b/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt new file mode 100644 index 0000000000..efbb919a33 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/logging/SentryUserObserver.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.logging + +import java.util.UUID +import io.sentry.Sentry +import io.sentry.protocol.User +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.util.kotlin.CoroutineScopeProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SentryUserObserver @Inject constructor( + private val scopeProvider: CoroutineScopeProvider, + internal val accountManager: AccountManager +) { + + fun start() = accountManager.getPrimaryUserId() + .map { userId -> + val user = User().apply { id = userId?.id ?: UUID.randomUUID().toString() } + Sentry.setUser(user) + } + .launchIn(scopeProvider.GlobalDefaultSupervisedScope) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt new file mode 100644 index 0000000000..84fa2b383b --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/Home.kt @@ -0,0 +1,704 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarResult +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import ch.protonmail.android.LockScreenActivity +import ch.protonmail.android.MainActivity +import ch.protonmail.android.R +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.compose.UndoableOperationSnackbar +import ch.protonmail.android.mailcommon.presentation.extension.navigateBack +import ch.protonmail.android.mailcommon.presentation.model.ActionResult +import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetail +import ch.protonmail.android.maildetail.presentation.ui.MessageDetail +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType +import ch.protonmail.android.mailnotifications.presentation.EnablePushNotificationsDialog +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState +import ch.protonmail.android.mailsidebar.presentation.Sidebar +import ch.protonmail.android.mailupselling.presentation.ui.screen.UpsellingScreen +import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect +import ch.protonmail.android.navigation.model.Destination.Dialog +import ch.protonmail.android.navigation.model.Destination.Screen +import ch.protonmail.android.navigation.route.addAccountSettings +import ch.protonmail.android.navigation.route.addAlternativeRoutingSetting +import ch.protonmail.android.navigation.route.addAutoDeleteSettings +import ch.protonmail.android.navigation.route.addAutoLockPinScreen +import ch.protonmail.android.navigation.route.addAutoLockSettings +import ch.protonmail.android.navigation.route.addCombinedContactsSetting +import ch.protonmail.android.navigation.route.addComposer +import ch.protonmail.android.navigation.route.addContactDetails +import ch.protonmail.android.navigation.route.addContactForm +import ch.protonmail.android.navigation.route.addContactGroupDetails +import ch.protonmail.android.navigation.route.addContactGroupForm +import ch.protonmail.android.navigation.route.addContactSearch +import ch.protonmail.android.navigation.route.addContacts +import ch.protonmail.android.navigation.route.addConversationDetail +import ch.protonmail.android.navigation.route.addConversationModeSettings +import ch.protonmail.android.navigation.route.addCustomizeToolbar +import ch.protonmail.android.navigation.route.addDeepLinkHandler +import ch.protonmail.android.navigation.route.addDefaultEmailSettings +import ch.protonmail.android.navigation.route.addDisplayNameSettings +import ch.protonmail.android.navigation.route.addEditSwipeActionsSettings +import ch.protonmail.android.navigation.route.addExportLogsSettings +import ch.protonmail.android.navigation.route.addFolderForm +import ch.protonmail.android.navigation.route.addFolderList +import ch.protonmail.android.navigation.route.addLabelForm +import ch.protonmail.android.navigation.route.addLabelList +import ch.protonmail.android.navigation.route.addLanguageSettings +import ch.protonmail.android.navigation.route.addMailbox +import ch.protonmail.android.navigation.route.addManageMembers +import ch.protonmail.android.navigation.route.addEntireMessageBody +import ch.protonmail.android.navigation.route.addMessageDetail +import ch.protonmail.android.navigation.route.addNotificationsSettings +import ch.protonmail.android.navigation.route.addParentFolderList +import ch.protonmail.android.navigation.route.addPrivacySettings +import ch.protonmail.android.navigation.route.addRemoveAccountDialog +import ch.protonmail.android.navigation.route.addSetMessagePassword +import ch.protonmail.android.navigation.route.addSettings +import ch.protonmail.android.navigation.route.addSignOutAccountDialog +import ch.protonmail.android.navigation.route.addSwipeActionsSettings +import ch.protonmail.android.navigation.route.addThemeSettings +import ch.protonmail.android.navigation.route.addUpsellingRoutes +import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost +import io.sentry.compose.withSentryObservableEffect +import kotlinx.coroutines.launch +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.network.domain.NetworkStatus + +@Composable +@Suppress("ComplexMethod") +fun Home( + activityActions: MainActivity.Actions, + launcherActions: Launcher.Actions, + viewModel: HomeViewModel = hiltViewModel() +) { + val navController = rememberNavController() + .withSentryObservableEffect() + .withDestinationChangedObservableEffect() + var isNavHostReady by remember { mutableStateOf(false) } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestinationRoute = navBackStackEntry?.destination?.route + + val scaffoldState = rememberScaffoldState() + val snackbarHostSuccessState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.SUCCESS) } + val snackbarHostWarningState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.WARNING) } + val snackbarHostNormState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.NORM) } + val snackbarHostErrorState = remember { ProtonSnackbarHostState(defaultType = ProtonSnackbarType.ERROR) } + val scope = rememberCoroutineScope() + val state by viewModel.state.collectAsStateWithLifecycle() + + val offlineSnackbarMessage = stringResource(id = R.string.you_are_offline) + fun showOfflineSnackbar() = scope.launch { + snackbarHostWarningState.showSnackbar( + message = offlineSnackbarMessage, + type = ProtonSnackbarType.WARNING + ) + } + + ConsumableLaunchedEffect(state.networkStatusEffect) { + if (it == NetworkStatus.Disconnected) { + showOfflineSnackbar() + } + } + + // Ensure that the navigation graph is defined and the composable routes attached to it. + LaunchedEffect(state.navigateToEffect, isNavHostReady) { + if (!isNavHostReady) return@LaunchedEffect + + state.navigateToEffect.consume()?.let { + viewModel.navigateTo(navController, it) + } + } + + val featureMissingSnackbarMessage = stringResource(id = R.string.feature_coming_soon) + fun showFeatureMissingSnackbar() = scope.launch { + snackbarHostNormState.showSnackbar( + message = featureMissingSnackbarMessage, + type = ProtonSnackbarType.NORM + ) + } + + fun showErrorSnackbar(text: String) = scope.launch { + snackbarHostErrorState.showSnackbar( + message = text, + type = ProtonSnackbarType.ERROR + ) + } + + fun showNormalSnackbar(text: String) = scope.launch { + snackbarHostErrorState.showSnackbar( + message = text, + type = ProtonSnackbarType.NORM + ) + } + + val draftSavedText = stringResource(id = R.string.mailbox_draft_saved) + val draftSavedDiscardText = stringResource(id = R.string.mailbox_draft_discard) + fun showDraftSavedSnackbar(messageId: MessageId) = scope.launch { + val result = snackbarHostSuccessState.showSnackbar( + message = draftSavedText, + type = ProtonSnackbarType.SUCCESS, + actionLabel = draftSavedDiscardText + + ) + when (result) { + SnackbarResult.ActionPerformed -> viewModel.discardDraft(messageId) + SnackbarResult.Dismissed -> Unit + } + } + + val sendingMessageText = stringResource(id = R.string.mailbox_message_sending) + fun showMessageSendingSnackbar() = scope.launch { + snackbarHostNormState.showSnackbar(message = sendingMessageText, type = ProtonSnackbarType.NORM) + } + + val sendingMessageOfflineText = stringResource(id = R.string.mailbox_message_sending_offline) + fun showMessageSendingOfflineSnackbar() = scope.launch { + snackbarHostNormState.showSnackbar(message = sendingMessageOfflineText, type = ProtonSnackbarType.NORM) + } + + val successSendingMessageText = stringResource(id = R.string.mailbox_message_sending_success) + fun showSuccessSendingMessageSnackbar() = scope.launch { + snackbarHostSuccessState.showSnackbar(message = successSendingMessageText, type = ProtonSnackbarType.SUCCESS) + } + + val errorSendingMessageText = stringResource(id = R.string.mailbox_message_sending_error) + val errorSendingMessageActionText = stringResource(id = R.string.mailbox_message_sending_error_action) + fun showErrorSendingMessageSnackbar() = scope.launch { + val shouldShowAction = viewModel.shouldNavigateToDraftsOnSendingFailure(navController.currentDestination) + val result = snackbarHostErrorState.showSnackbar( + type = ProtonSnackbarType.ERROR, + message = errorSendingMessageText, + actionLabel = if (shouldShowAction) errorSendingMessageActionText else null, + duration = if (shouldShowAction) SnackbarDuration.Long else SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> viewModel.navigateToDrafts(navController) + SnackbarResult.Dismissed -> Unit + } + } + + val errorUploadAttachmentText = stringResource(id = R.string.mailbox_attachment_uploading_error) + fun showErrorUploadAttachmentSnackbar() = scope.launch { + snackbarHostErrorState.showSnackbar(message = errorUploadAttachmentText, type = ProtonSnackbarType.ERROR) + } + + val labelSavedText = stringResource(id = R.string.label_saved) + fun showLabelSavedSnackbar() = scope.launch { + snackbarHostSuccessState.showSnackbar(message = labelSavedText, type = ProtonSnackbarType.SUCCESS) + } + + val labelDeletedText = stringResource(id = R.string.label_deleted) + fun showLabelDeletedSnackbar() = scope.launch { + snackbarHostSuccessState.showSnackbar(message = labelDeletedText, type = ProtonSnackbarType.SUCCESS) + } + + fun showUpsellingSnackbar(message: String) = scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + + fun showUpsellingErrorSnackbar(message: String) = scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + + val labelListErrorLoadingText = stringResource(id = R.string.label_list_loading_error) + fun showLabelListErrorLoadingSnackbar() = scope.launch { + snackbarHostErrorState.showSnackbar(message = labelListErrorLoadingText, type = ProtonSnackbarType.ERROR) + } + + val undoActionEffect = remember { mutableStateOf(Effect.empty()) } + UndoableOperationSnackbar(snackbarHostState = snackbarHostNormState, actionEffect = undoActionEffect.value) + fun showUndoableOperationSnackbar(actionResult: ActionResult) = scope.launch { + undoActionEffect.value = Effect.of(actionResult) + } + + ConsumableLaunchedEffect(state.messageSendingStatusEffect) { sendingStatus -> + when (sendingStatus) { + is MessageSendingStatus.MessageSent -> showSuccessSendingMessageSnackbar() + is MessageSendingStatus.SendMessageError -> showErrorSendingMessageSnackbar() + is MessageSendingStatus.UploadAttachmentsError -> showErrorUploadAttachmentSnackbar() + is MessageSendingStatus.None -> {} + } + } + + when (val notificationPermissionDialogState = state.notificationPermissionDialogState) { + is NotificationPermissionDialogState.Hidden -> Unit + is NotificationPermissionDialogState.Shown -> { + EnablePushNotificationsDialog( + state = notificationPermissionDialogState, + onEnable = { + launcherActions.onRequestNotificationPermission() + viewModel.closeNotificationPermissionDialog() + viewModel.trackTelemetryEvent( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogEnable( + notificationPermissionDialogState.type + ) + ) + }, + onDismiss = { + viewModel.closeNotificationPermissionDialog() + viewModel.trackTelemetryEvent( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDismiss( + notificationPermissionDialogState.type + ) + ) + } + ) + } + } + + Scaffold( + scaffoldState = scaffoldState, + drawerShape = RectangleShape, + drawerScrimColor = ProtonTheme.colors.blenderNorm, + drawerContent = { + Sidebar( + drawerState = scaffoldState.drawerState, + navigationActions = buildSidebarActions(navController, launcherActions) + ) + }, + drawerGesturesEnabled = currentDestinationRoute == Screen.Mailbox.route, + snackbarHost = { + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHostSuccess), + protonSnackbarHostState = snackbarHostSuccessState + ) + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHostWarning), + protonSnackbarHostState = snackbarHostWarningState + ) + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHostNormal), + protonSnackbarHostState = snackbarHostNormState + ) + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHostError), + protonSnackbarHostState = snackbarHostErrorState + ) + } + ) { contentPadding -> + Box( + Modifier.padding(contentPadding) + ) { + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = Screen.Mailbox.route + ) { + // home + addConversationDetail( + actions = ConversationDetail.Actions( + onExit = { notifyUserMessage -> + navController.navigateBack() + notifyUserMessage?.let { showUndoableOperationSnackbar(it) } + viewModel.recordViewOfMailboxScreen() + }, + openMessageBodyLink = activityActions.openInActivityInNewTask, + openAttachment = activityActions.openIntentChooser, + handleProtonCalendarRequest = activityActions.openProtonCalendarIntentValues, + onAddLabel = { navController.navigate(Screen.LabelList.route) }, + onAddFolder = { navController.navigate(Screen.FolderList.route) }, + showFeatureMissingSnackbar = { showFeatureMissingSnackbar() }, + onReply = { navController.navigate(Screen.MessageActionComposer(DraftAction.Reply(it))) }, + onReplyAll = { navController.navigate(Screen.MessageActionComposer(DraftAction.ReplyAll(it))) }, + onForward = { navController.navigate(Screen.MessageActionComposer(DraftAction.Forward(it))) }, + onViewContactDetails = { navController.navigate(Screen.ContactDetails(it)) }, + onAddContact = { basicContactInfo -> + navController.navigate(Screen.AddContact(basicContactInfo)) + }, + onComposeNewMessage = { + navController.navigate( + Screen.MessageActionComposer( + DraftAction.ComposeToAddresses( + listOf(it) + ) + ) + ) + }, + navigateToCustomizeToolbar = { + navController.navigate(Screen.CustomizeToolbar.route) + }, + openComposerForDraftMessage = { navController.navigate(Screen.EditDraftComposer(it)) }, + showSnackbar = { message -> + scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + }, + recordMailboxScreenView = { viewModel.recordViewOfMailboxScreen() }, + onViewEntireMessageClicked = + { messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference -> + navController.navigate( + Screen.EntireMessageBody( + messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference + ) + ) + } + ) + ) + addMailbox( + navController, + drawerState = scaffoldState.drawerState, + showOfflineSnackbar = { showOfflineSnackbar() }, + showNormalSnackbar = { showNormalSnackbar(it) }, + showErrorSnackbar = { showErrorSnackbar(it) }, + onRequestNotificationPermission = launcherActions.onRequestNotificationPermission + ) + addMessageDetail( + actions = MessageDetail.Actions( + onExit = { notifyUserMessage -> + navController.navigateBack() + notifyUserMessage?.let { showUndoableOperationSnackbar(it) } + viewModel.recordViewOfMailboxScreen() + }, + openMessageBodyLink = activityActions.openInActivityInNewTask, + openAttachment = activityActions.openIntentChooser, + handleProtonCalendarRequest = activityActions.openProtonCalendarIntentValues, + onAddLabel = { navController.navigate(Screen.LabelList.route) }, + onAddFolder = { navController.navigate(Screen.FolderList.route) }, + showFeatureMissingSnackbar = { showFeatureMissingSnackbar() }, + onReply = { navController.navigate(Screen.MessageActionComposer(DraftAction.Reply(it))) }, + onReplyAll = { navController.navigate(Screen.MessageActionComposer(DraftAction.ReplyAll(it))) }, + onForward = { navController.navigate(Screen.MessageActionComposer(DraftAction.Forward(it))) }, + onViewContactDetails = { navController.navigate(Screen.ContactDetails(it)) }, + onAddContact = { basicContactInfo -> + navController.navigate(Screen.AddContact(basicContactInfo)) + }, + onComposeNewMessage = { + navController.navigate( + Screen.MessageActionComposer( + DraftAction.ComposeToAddresses( + listOf(it) + ) + ) + ) + }, + showSnackbar = { message -> + scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + }, + navigateToCustomizeToolbar = { + navController.navigate(Screen.CustomizeToolbar.route) + }, + recordMailboxScreenView = { viewModel.recordViewOfMailboxScreen() }, + onViewEntireMessageClicked = + { messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference -> + navController.navigate( + Screen.EntireMessageBody( + messageId, shouldShowEmbeddedImages, shouldShowRemoteContent, viewModePreference + ) + ) + } + ) + ) + addEntireMessageBody( + navController, + onOpenMessageBodyLink = activityActions.openInActivityInNewTask + ) + addComposer( + navController, + activityActions, + showDraftSavedSnackbar = { showDraftSavedSnackbar(it) }, + showMessageSendingSnackbar = { showMessageSendingSnackbar() }, + showMessageSendingOfflineSnackbar = { showMessageSendingOfflineSnackbar() }, + showComposerV2 = viewModel.isComposerV2Enabled + ) + + addSetMessagePassword(navController) + addSignOutAccountDialog(navController) + addRemoveAccountDialog(navController) + addSettings(navController) + addLabelList( + navController, + showLabelListErrorLoadingSnackbar = { showLabelListErrorLoadingSnackbar() } + ) + addLabelForm( + navController, + showLabelSavedSnackbar = { showLabelSavedSnackbar() }, + showLabelDeletedSnackbar = { showLabelDeletedSnackbar() }, + showUpsellingSnackbar = { showUpsellingSnackbar(it) }, + showUpsellingErrorSnackbar = { showUpsellingErrorSnackbar(it) } + ) + addFolderList( + navController, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + } + ) + addFolderForm( + navController, + showSuccessSnackbar = { message -> + scope.launch { + snackbarHostSuccessState.showSnackbar( + message = message, + type = ProtonSnackbarType.SUCCESS + ) + } + }, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + }, + showNormSnackbar = { message -> + scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + } + ) + addParentFolderList( + navController, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + } + ) + // settings + addAccountSettings(navController, launcherActions, activityActions) + addContacts( + navController, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + }, + showNormalSnackbar = { + showNormalSnackbar(it) + }, + showFeatureMissingSnackbar = { + showFeatureMissingSnackbar() + } + ) + addContactDetails( + navController, + showSuccessSnackbar = { message -> + scope.launch { + snackbarHostSuccessState.showSnackbar( + message = message, + type = ProtonSnackbarType.SUCCESS + ) + } + }, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + }, + showFeatureMissingSnackbar = { + showFeatureMissingSnackbar() + } + ) + addContactForm( + navController, + showSuccessSnackbar = { message -> + scope.launch { + snackbarHostSuccessState.showSnackbar( + message = message, + type = ProtonSnackbarType.SUCCESS + ) + } + }, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + } + ) + addContactGroupDetails( + navController, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + }, + showNormSnackbar = { message -> + scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + } + ) + addContactGroupForm( + navController, + showSuccessSnackbar = { message -> + scope.launch { + snackbarHostSuccessState.showSnackbar( + message = message, + type = ProtonSnackbarType.SUCCESS + ) + } + }, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + }, + showNormSnackbar = { message -> + scope.launch { + snackbarHostNormState.showSnackbar( + message = message, + type = ProtonSnackbarType.NORM + ) + } + } + ) + addManageMembers( + navController, + showErrorSnackbar = { message -> + scope.launch { + snackbarHostErrorState.showSnackbar( + message = message, + type = ProtonSnackbarType.ERROR + ) + } + } + ) + addContactSearch( + navController + ) + addAlternativeRoutingSetting(navController) + addCombinedContactsSetting(navController) + addConversationModeSettings(navController) + addAutoDeleteSettings(navController) + addDefaultEmailSettings(navController) + addDisplayNameSettings(navController) + addEditSwipeActionsSettings(navController) + addLanguageSettings(navController) + addCustomizeToolbar(navController) + addPrivacySettings(navController) + addAutoLockSettings(navController) + addAutoLockPinScreen( + onBack = { navController.navigateBack() }, + onShowSuccessSnackbar = { + scope.launch { + snackbarHostSuccessState.showSnackbar(message = it, type = ProtonSnackbarType.SUCCESS) + } + }, + activityActions = LockScreenActivity.Actions.Empty + ) + addSwipeActionsSettings(navController) + addThemeSettings(navController) + addNotificationsSettings(navController) + addExportLogsSettings(navController) + addDeepLinkHandler(navController) + addUpsellingRoutes( + UpsellingScreen.Actions.Empty.copy( + onDismiss = { navController.navigateBack() }, + onUpgrade = { message -> scope.launch { showNormalSnackbar(message) } }, + onError = { message -> scope.launch { showErrorSnackbar(message) } } + ) + ) + + isNavHostReady = true + } + } + } +} + +private fun buildSidebarActions(navController: NavHostController, launcherActions: Launcher.Actions) = + Sidebar.NavigationActions( + onSignIn = launcherActions.onSignIn, + onSignOut = { navController.navigate(Dialog.SignOut(it)) }, + onUpsell = { navController.navigate(Screen.Upselling.StandaloneNavbar.route) }, + onRemoveAccount = { navController.navigate(Dialog.RemoveAccount(it)) }, + onSwitchAccount = launcherActions.onSwitchAccount, + onSettings = { navController.navigate(Screen.Settings.route) }, + onLabelList = { navController.navigate(Screen.LabelList.route) }, + onFolderList = { navController.navigate(Screen.FolderList.route) }, + onLabelAdd = { navController.navigate(Screen.CreateLabel.route) }, + onFolderAdd = { navController.navigate(Screen.CreateFolder.route) }, + onSubscription = launcherActions.onSubscription, + onContacts = { navController.navigate(Screen.Contacts.route) }, + onReportBug = launcherActions.onReportBug + ) diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt new file mode 100644 index 0000000000..a3abb9cf45 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/HomeViewModel.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import ch.protonmail.android.mailcommon.data.file.getShareInfo +import ch.protonmail.android.mailcommon.data.file.isStartedFromLauncher +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailcommon.domain.model.encode +import ch.protonmail.android.mailcommon.domain.model.isNotEmpty +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.domain.annotation.IsComposerV2Enabled +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.mailcomposer.domain.usecase.DiscardDraft +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveSendingMessagesStatus +import ch.protonmail.android.mailcomposer.domain.usecase.ResetSendingMessagesStatus +import ch.protonmail.android.maillabel.domain.SelectedMailLabelId +import ch.protonmail.android.maillabel.domain.model.MailLabelId +import ch.protonmail.android.mailmailbox.domain.usecase.RecordMailboxScreenView +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType +import ch.protonmail.android.mailnotifications.domain.usecase.SavePermissionDialogTimestamp +import ch.protonmail.android.mailnotifications.domain.usecase.SaveShouldStopShowingPermissionDialog +import ch.protonmail.android.mailnotifications.domain.usecase.ShouldShowNotificationPermissionDialog +import ch.protonmail.android.mailnotifications.domain.usecase.TrackNotificationPermissionTelemetryEvent +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogType +import ch.protonmail.android.navigation.model.Destination +import ch.protonmail.android.navigation.model.HomeState +import ch.protonmail.android.navigation.share.ShareIntentObserver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.proton.core.network.domain.NetworkManager +import me.proton.core.network.domain.NetworkStatus +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val networkManager: NetworkManager, + private val observeSendingMessagesStatus: ObserveSendingMessagesStatus, + private val recordMailboxScreenView: RecordMailboxScreenView, + private val resetSendingMessageStatus: ResetSendingMessagesStatus, + private val selectedMailLabelId: SelectedMailLabelId, + private val discardDraft: DiscardDraft, + private val shouldShowNotificationPermissionDialog: ShouldShowNotificationPermissionDialog, + private val savePermissionDialogTimestamp: SavePermissionDialogTimestamp, + private val saveShouldStopShowingPermissionDialog: SaveShouldStopShowingPermissionDialog, + private val trackNotificationPermissionTelemetryEvent: TrackNotificationPermissionTelemetryEvent, + @IsComposerV2Enabled val isComposerV2Enabled: Boolean, + observePrimaryUser: ObservePrimaryUser, + shareIntentObserver: ShareIntentObserver +) : ViewModel() { + + private val primaryUser = observePrimaryUser().filterNotNull() + + private val mutableState = MutableStateFlow(HomeState.Initial) + + val state: StateFlow = mutableState + + init { + observeNetworkStatus().onEach { networkStatus -> + if (networkStatus == NetworkStatus.Disconnected) { + delay(NetworkStatusUpdateDelay) + emitNewStateFor(networkManager.networkStatus) + } else { + emitNewStateFor(networkStatus) + } + }.launchIn(viewModelScope) + + primaryUser.flatMapLatest { user -> + observeSendingMessagesStatus(user.userId) + }.onEach { + emitNewStateFor(it) + resetSendingMessageStatus(primaryUser.first().userId) + }.launchIn(viewModelScope) + + shareIntentObserver() + .onEach { intent -> + emitNewStateForIntent(intent) + } + .launchIn(viewModelScope) + + showNotificationPermissionDialogIfNeeded(isMessageSent = false) + } + + fun navigateTo(navController: NavController, route: String) { + navController.navigate(route = route) + } + + /** + * Navigate to Drafts only when: + * - we are outside of Mailbox + * - we are in Mailbox but not in Drafts + */ + fun shouldNavigateToDraftsOnSendingFailure(currentNavDestination: NavDestination?): Boolean = + currentNavDestination?.route != Destination.Screen.Mailbox.route || + selectedMailLabelId.flow.value.labelId != MailLabelId.System.AllDrafts.labelId && + selectedMailLabelId.flow.value.labelId != MailLabelId.System.Drafts.labelId + + fun navigateToDrafts(navController: NavController) { + if (navController.currentDestination?.route != Destination.Screen.Mailbox.route) { + navController.popBackStack(Destination.Screen.Mailbox.route, inclusive = false) + } + selectedMailLabelId.set(MailLabelId.System.Drafts) + } + + fun discardDraft(messageId: MessageId) { + viewModelScope.launch { + primaryUser.firstOrNull()?.let { + discardDraft(it.userId, messageId) + } ?: Timber.e("Primary user is not available!") + } + } + + fun recordViewOfMailboxScreen() = recordMailboxScreenView() + + fun closeNotificationPermissionDialog() { + mutableState.update { currentState -> + currentState.copy( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden + ) + } + } + + fun trackTelemetryEvent(eventType: NotificationPermissionTelemetryEventType) = + trackNotificationPermissionTelemetryEvent(eventType) + + private fun emitNewStateFor(messageSendingStatus: MessageSendingStatus) { + if (messageSendingStatus == MessageSendingStatus.None) { + // Emitting a None status to UI would override the previously emitted effect and cause snack not to show + return + } + + if (messageSendingStatus == MessageSendingStatus.MessageSent) { + showNotificationPermissionDialogIfNeeded(isMessageSent = true) + } + + mutableState.update { currentState -> + currentState.copy( + messageSendingStatusEffect = Effect.of(messageSendingStatus) + ) + } + } + + private fun emitNewStateForIntent(intent: Intent) { + if (intent.isStartedFromLauncher()) { + mutableState.update { currentState -> + currentState.copy(startedFromLauncher = true) + } + } else if (!mutableState.value.startedFromLauncher) { + val intentShareInfo = intent.getShareInfo() + if (intentShareInfo.isNotEmpty()) { + emitNewStateForShareVia(intentShareInfo) + } + } else { + Timber.d("Share intent is not processed as this instance was started from launcher!") + } + } + + private fun emitNewStateForShareVia(intentShareInfo: IntentShareInfo) { + mutableState.update { currentState -> + currentState.copy( + navigateToEffect = Effect.of( + Destination.Screen.ShareFileComposer(DraftAction.PrefillForShare(intentShareInfo.encode())) + ) + ) + } + } + + private fun emitNewStateFor(networkStatus: NetworkStatus) { + mutableState.update { currentState -> + currentState.copy(networkStatusEffect = Effect.of(networkStatus)) + } + } + + private fun observeNetworkStatus() = networkManager.observe().distinctUntilChanged() + + private fun showNotificationPermissionDialogIfNeeded(isMessageSent: Boolean) { + viewModelScope.launch { + if (!shouldShowNotificationPermissionDialog(System.currentTimeMillis(), isMessageSent)) return@launch + + val notificationPermissionDialogType = if (isMessageSent) { + NotificationPermissionDialogType.PostSending + } else { + NotificationPermissionDialogType.PostOnboarding + } + + mutableState.update { currentState -> + currentState.copy( + notificationPermissionDialogState = NotificationPermissionDialogState.Shown( + type = notificationPermissionDialogType + ) + ) + } + + trackTelemetryEvent( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed( + notificationPermissionDialogType + ) + ) + + if (isMessageSent) { + saveShouldStopShowingPermissionDialog() + } else { + savePermissionDialogTimestamp(System.currentTimeMillis()) + } + } + } + + companion object { + + const val NetworkStatusUpdateDelay = 5000L + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt new file mode 100644 index 0000000000..5cafe9f46f --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/Launcher.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import ch.protonmail.android.MainActivity +import ch.protonmail.android.navigation.model.LauncherState +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.domain.entity.UserId + +@Composable +fun Launcher(activityActions: MainActivity.Actions, viewModel: LauncherViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState(LauncherState.Processing) + + when (state) { + LauncherState.AccountNeeded -> viewModel.submit(LauncherViewModel.Action.AddAccount) + LauncherState.PrimaryExist -> LauncherRouter( + activityActions = activityActions, + launcherActions = Launcher.Actions( + onPasswordManagement = { viewModel.submit(LauncherViewModel.Action.OpenPasswordManagement) }, + onRecoveryEmail = { viewModel.submit(LauncherViewModel.Action.OpenRecoveryEmail) }, + onReportBug = { viewModel.submit(LauncherViewModel.Action.OpenReport) }, + onSignIn = { viewModel.submit(LauncherViewModel.Action.SignIn(it)) }, + onSubscription = { viewModel.submit(LauncherViewModel.Action.OpenSubscription) }, + onSwitchAccount = { viewModel.submit(LauncherViewModel.Action.Switch(it)) }, + onRequestNotificationPermission = { + viewModel.submit(LauncherViewModel.Action.RequestNotificationPermission) + } + ) + ) + LauncherState.Processing, + LauncherState.StepNeeded -> ProtonCenteredProgress(Modifier.fillMaxSize()) + } +} + +object Launcher { + + /** + * A set of actions that can be executed in the scope of Core's Orchestrators + */ + data class Actions( + val onSignIn: (UserId?) -> Unit, + val onSwitchAccount: (UserId) -> Unit, + val onSubscription: () -> Unit, + val onReportBug: () -> Unit, + val onPasswordManagement: () -> Unit, + val onRecoveryEmail: () -> Unit, + val onRequestNotificationPermission: () -> Unit + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt new file mode 100644 index 0000000000..35d3bc8125 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.MainActivity +import ch.protonmail.android.navigation.model.OnboardingEligibilityState +import ch.protonmail.android.navigation.onboarding.Onboarding +import me.proton.core.compose.component.ProtonCenteredProgress + +@Composable +internal fun LauncherRouter( + activityActions: MainActivity.Actions, + launcherActions: Launcher.Actions, + viewModel: LauncherRouterViewModel = hiltViewModel() +) { + + val onboardingState by viewModel.onboardingEligibilityState.collectAsStateWithLifecycle() + + when (onboardingState) { + OnboardingEligibilityState.Loading -> ProtonCenteredProgress(Modifier.fillMaxSize()) + OnboardingEligibilityState.NotRequired -> Home(activityActions, launcherActions) + OnboardingEligibilityState.Required -> Onboarding() + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt new file mode 100644 index 0000000000..e5c80f9c03 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModel.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.PreferencesError +import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference +import ch.protonmail.android.mailonboarding.domain.usecase.ObserveOnboarding +import ch.protonmail.android.navigation.model.OnboardingEligibilityState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +internal class LauncherRouterViewModel @Inject constructor( + observeOnboarding: ObserveOnboarding +) : ViewModel() { + + val onboardingEligibilityState = observeOnboarding() + .mapLatest { it.toState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = OnboardingEligibilityState.Loading + ) + + private fun Either.toState(): OnboardingEligibilityState { + val preference = this.getOrNull() ?: return OnboardingEligibilityState.Required + return if (preference.display) OnboardingEligibilityState.Required else OnboardingEligibilityState.NotRequired + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt new file mode 100644 index 0000000000..cd547b54e3 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/LauncherViewModel.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailnotifications.presentation.NotificationPermissionOrchestrator +import ch.protonmail.android.navigation.model.LauncherState +import ch.protonmail.android.navigation.model.LauncherState.AccountNeeded +import ch.protonmail.android.navigation.model.LauncherState.PrimaryExist +import ch.protonmail.android.navigation.model.LauncherState.Processing +import ch.protonmail.android.navigation.model.LauncherState.StepNeeded +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import me.proton.core.account.domain.entity.isDisabled +import me.proton.core.account.domain.entity.isReady +import me.proton.core.account.domain.entity.isStepNeeded +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.accountmanager.presentation.observe +import me.proton.core.accountmanager.presentation.onAccountCreateAddressFailed +import me.proton.core.accountmanager.presentation.onAccountCreateAddressNeeded +import me.proton.core.accountmanager.presentation.onAccountDeviceSecretNeeded +import me.proton.core.accountmanager.presentation.onAccountTwoPassModeFailed +import me.proton.core.accountmanager.presentation.onAccountTwoPassModeNeeded +import me.proton.core.accountmanager.presentation.onSessionSecondFactorNeeded +import me.proton.core.auth.presentation.AuthOrchestrator +import me.proton.core.auth.presentation.onAddAccountResult +import me.proton.core.domain.entity.UserId +import me.proton.core.plan.presentation.PlansOrchestrator +import me.proton.core.report.presentation.ReportOrchestrator +import me.proton.core.usersettings.presentation.UserSettingsOrchestrator +import javax.inject.Inject + +@HiltViewModel +class LauncherViewModel @Inject constructor( + private val accountManager: AccountManager, + private val authOrchestrator: AuthOrchestrator, + private val notificationPermissionOrchestrator: NotificationPermissionOrchestrator, + private val plansOrchestrator: PlansOrchestrator, + private val reportOrchestrator: ReportOrchestrator, + private val userSettingsOrchestrator: UserSettingsOrchestrator +) : ViewModel() { + + val state: StateFlow = accountManager.getAccounts().map { accounts -> + when { + accounts.isEmpty() || accounts.all { it.isDisabled() } -> AccountNeeded + accounts.any { it.isReady() } -> PrimaryExist + accounts.any { it.isStepNeeded() } -> StepNeeded + else -> Processing + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = Processing + ) + + fun register(context: AppCompatActivity) { + authOrchestrator.register(context) + plansOrchestrator.register(context) + reportOrchestrator.register(context) + userSettingsOrchestrator.register(context) + notificationPermissionOrchestrator.register(context) + + authOrchestrator.onAddAccountResult { result -> + viewModelScope.launch { + if (result == null && getPrimaryUserIdOrNull() == null) { + context.finish() + } + } + } + + accountManager.observe(context.lifecycle, Lifecycle.State.CREATED) + .onAccountTwoPassModeFailed { accountManager.disableAccount(it.userId) } + .onAccountCreateAddressFailed { accountManager.disableAccount(it.userId) } + .onSessionSecondFactorNeeded { authOrchestrator.startSecondFactorWorkflow(it) } + .onAccountTwoPassModeNeeded { authOrchestrator.startTwoPassModeWorkflow(it) } + .onAccountCreateAddressNeeded { authOrchestrator.startChooseAddressWorkflow(it) } + .onAccountDeviceSecretNeeded { authOrchestrator.startDeviceSecretWorkflow(it) } + } + + fun unregister() { + authOrchestrator.unregister() + plansOrchestrator.unregister() + reportOrchestrator.unregister() + userSettingsOrchestrator.unregister() + notificationPermissionOrchestrator.unregister() + } + + fun submit(action: Action) { + viewModelScope.launch { + when (action) { + Action.AddAccount -> onAddAccount() + Action.OpenPasswordManagement -> onOpenPasswordManagement() + Action.OpenRecoveryEmail -> onOpenRecoveryEmail() + Action.OpenSecurityKeys -> onOpenSecurityKeys() + Action.OpenReport -> onOpenReport() + Action.OpenSubscription -> onOpenSubscription() + Action.RequestNotificationPermission -> onRequestNotificationPermission() + is Action.SignIn -> onSignIn(action.userId) + is Action.Switch -> onSwitch(action.userId) + } + } + } + + private fun onAddAccount() { + authOrchestrator.startAddAccountWorkflow() + } + + private suspend fun onOpenPasswordManagement() { + getPrimaryUserIdOrNull()?.let { + userSettingsOrchestrator.startPasswordManagementWorkflow(it) + } + } + + private suspend fun onOpenRecoveryEmail() { + getPrimaryUserIdOrNull()?.let { + userSettingsOrchestrator.startUpdateRecoveryEmailWorkflow(it) + } + } + + private suspend fun onOpenSecurityKeys() { + getPrimaryUserIdOrNull()?.let { + userSettingsOrchestrator.startSecurityKeysWorkflow(it) + } + } + + private suspend fun onOpenReport() = viewModelScope.launch { + reportOrchestrator.startBugReport() + } + + private suspend fun onOpenSubscription() { + getPrimaryUserIdOrNull()?.let { + plansOrchestrator.showCurrentPlanWorkflow(it) + } + } + + private fun onRequestNotificationPermission() { + notificationPermissionOrchestrator.requestPermissionIfRequired() + } + + private suspend fun onSignIn(userId: UserId?) { + val account = userId?.let { getAccountOrNull(it) } + authOrchestrator.startLoginWorkflow(account?.username) + } + + private suspend fun onSwitch(userId: UserId) { + val account = getAccountOrNull(userId) ?: return + when { + account.isDisabled() -> onSignIn(userId) + account.isReady() -> accountManager.setAsPrimary(userId) + } + } + + private suspend fun getAccountOrNull(it: UserId) = accountManager.getAccount(it).firstOrNull() + private suspend fun getPrimaryUserIdOrNull() = accountManager.getPrimaryUserId().firstOrNull() + + sealed interface Action { + + data object AddAccount : Action + data object OpenPasswordManagement : Action + data object OpenRecoveryEmail : Action + data object OpenSecurityKeys : Action + data object OpenReport : Action + data object OpenSubscription : Action + data object RequestNotificationPermission : Action + data class SignIn(val userId: UserId?) : Action + data class Switch(val userId: UserId) : Action + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt new file mode 100644 index 0000000000..3b5c9c6afe --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinkHelperImpl.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.deeplinks + +import android.content.Context +import android.content.Intent +import ch.protonmail.android.MainActivity +import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class NotificationsDeepLinkHelperImpl @Inject constructor( + @ApplicationContext private val context: Context +) : NotificationsDeepLinkHelper { + + override fun buildMessageDeepLinkIntent( + notificationId: String, + messageId: String, + userId: String + ): Intent = Intent( + Intent.ACTION_VIEW, + buildMessageDeepLinkUri(notificationId, messageId, userId), + context, + MainActivity::class.java + ) + + override fun buildMessageGroupDeepLinkIntent(notificationId: String, userId: String): Intent = Intent( + Intent.ACTION_VIEW, + buildMessageGroupDeepLinkUri(notificationId, userId), + context, + MainActivity::class.java + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt new file mode 100644 index 0000000000..98c74d7666 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModel.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.deeplinks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailconversation.domain.repository.ConversationRepository +import ch.protonmail.android.mailmessage.domain.model.Message +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToInbox +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import me.proton.core.account.domain.entity.AccountState +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.accountmanager.domain.getAccounts +import me.proton.core.domain.entity.UserId +import me.proton.core.mailsettings.domain.entity.ViewMode +import me.proton.core.mailsettings.domain.repository.MailSettingsRepository +import me.proton.core.network.domain.NetworkManager +import me.proton.core.network.domain.NetworkStatus +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +@HiltViewModel +class NotificationsDeepLinksViewModel @Inject constructor( + private val networkManager: NetworkManager, + private val accountManager: AccountManager, + private val getPrimaryAddress: GetPrimaryAddress, + private val messageRepository: MessageRepository, + private val conversationRepository: ConversationRepository, + private val mailSettingsRepository: MailSettingsRepository +) : ViewModel() { + + private val _state = MutableStateFlow(State.Launched) + val state: StateFlow = _state + + private var navigateJob: Job? = null + + fun navigateToMessage(messageId: String, userId: String) { + if (isOffline()) { + navigateToInbox(userId) + } else { + navigateToMessageOrConversation(messageId, UserId(userId)) + } + } + + fun navigateToInbox(userId: String) { + viewModelScope.launch { + val activeUserId = accountManager.getPrimaryUserId().firstOrNull() + if (activeUserId != null && activeUserId.id != userId) { + switchUserAndNavigateToInbox(userId) + } else { + _state.value = NavigateToInbox.ActiveUser + } + } + } + + private suspend fun switchUserAndNavigateToInbox(userId: String) { + val switchAccountResult = switchActiveUserIfRequiredTo(userId) + _state.value = when (switchAccountResult) { + AccountSwitchResult.AccountSwitchError -> NavigateToInbox.ActiveUser + is AccountSwitchResult.AccountSwitched -> NavigateToInbox.ActiveUserSwitched(switchAccountResult.newEmail) + AccountSwitchResult.NotRequired -> NavigateToInbox.ActiveUser + } + } + + private fun navigateToMessageOrConversation(messageId: String, userId: UserId) { + navigateJob?.cancel() + navigateJob = viewModelScope.launch { + when (val switchAccountResult = switchActiveUserIfRequiredTo(userId.id)) { + AccountSwitchResult.AccountSwitchError -> navigateToInbox(userId.id) + is AccountSwitchResult.AccountSwitched -> navigateToMessageOrConversation( + this.coroutineContext, + messageId, + switchAccountResult.newUserId, + switchAccountResult.newEmail + ) + + AccountSwitchResult.NotRequired -> navigateToMessageOrConversation( + this.coroutineContext, + messageId, + userId + ) + } + } + } + + private suspend fun navigateToMessageOrConversation( + coroutineContext: CoroutineContext, + messageId: String, + userId: UserId, + switchedAccountEmail: String? = null + ) { + messageRepository.observeCachedMessage(userId, MessageId(messageId)) + .distinctUntilChanged() + .collectLatest { messageResult -> + messageResult + .onLeft { + if (it != DataError.Local.NoDataCached) navigateToInbox(userId.id) + } + .onRight { message -> + if (isConversationModeEnabled(userId)) { + navigateToConversation(message, userId, switchedAccountEmail) + } else { + _state.value = State.NavigateToMessageDetails(message.messageId, switchedAccountEmail) + } + coroutineContext.cancel() + } + } + } + + private suspend fun switchActiveUserIfRequiredTo(userId: String): AccountSwitchResult { + return if (accountManager.getPrimaryUserId().firstOrNull()?.id == userId) { + AccountSwitchResult.NotRequired + } else { + val targetAccount = accountManager.getAccounts(AccountState.Ready) + .firstOrNull() + ?.find { it.userId.id == userId } + ?: return AccountSwitchResult.AccountSwitchError + + accountManager.setAsPrimary(UserId(userId)) + val emailAddress = getPrimaryAddress(UserId(userId)).getOrNull()?.email + AccountSwitchResult.AccountSwitched(targetAccount.userId, emailAddress ?: "") + } + } + + private suspend fun isConversationModeEnabled(userId: UserId): Boolean = + mailSettingsRepository.getMailSettings(userId) + .viewMode + ?.value == ViewMode.ConversationGrouping.value + + private suspend fun navigateToConversation( + message: Message, + userId: UserId, + switchedAccountEmail: String? + ) { + conversationRepository.observeConversation( + userId, + message.conversationId, + true + ).collectLatest { conversationResult -> + conversationResult + .onLeft { + Timber.d("Conversation not found: $it") + if (it != DataError.Local.NoDataCached) navigateToInbox(userId.id) + } + .onRight { conversation -> + _state.value = + State.NavigateToConversation( + conversationId = conversation.conversationId, + userSwitchedEmail = switchedAccountEmail + ) + } + } + } + + private fun isOffline() = networkManager.networkStatus == NetworkStatus.Disconnected + + sealed interface State { + data object Launched : State + + sealed interface NavigateToInbox : State { + data object ActiveUser : NavigateToInbox + data class ActiveUserSwitched(val email: String) : NavigateToInbox + } + + data class NavigateToMessageDetails( + val messageId: MessageId, + val userSwitchedEmail: String? = null + ) : State + + data class NavigateToConversation( + val conversationId: ConversationId, + val scrollToMessageId: MessageId? = null, + val userSwitchedEmail: String? = null + ) : State + } + + private sealed interface AccountSwitchResult { + data object NotRequired : AccountSwitchResult + data class AccountSwitched(val newUserId: UserId, val newEmail: String) : AccountSwitchResult + + data object AccountSwitchError : AccountSwitchResult + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt new file mode 100644 index 0000000000..219fddd454 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavHostControllerExtension.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.listener + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.navigation.NavHostController +import timber.log.Timber + +@Composable +@NonRestartableComposable +fun NavHostController.withDestinationChangedObservableEffect(): NavHostController { + + val lifecycle = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(lifecycle, this) { + val observer = NavigationLifeCycleObserver( + this@withDestinationChangedObservableEffect, + navListener = { _, destination, _ -> + Timber.tag("NavController").d("Navigating to ${destination.route}") + } + ) + + lifecycle.addObserver(observer) + + onDispose { + observer.dispose() + lifecycle.removeObserver(observer) + } + } + return this +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt new file mode 100644 index 0000000000..1e223036f3 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/listener/NavigationLifeCycleObserver.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.listener + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController + +internal class NavigationLifeCycleObserver( + private val navController: NavController, + private val navListener: NavController.OnDestinationChangedListener +) : LifecycleEventObserver { + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_RESUME) { + navController.addOnDestinationChangedListener(navListener) + } else if (event == Lifecycle.Event.ON_PAUSE) { + navController.removeOnDestinationChangedListener(navListener) + } + } + + fun dispose() { + navController.removeOnDestinationChangedListener(navListener) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt new file mode 100644 index 0000000000..4f8c54f7c6 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/Destination.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.model + +import ch.protonmail.android.feature.account.SignOutAccountDialog.USER_ID_KEY +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode +import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView.ApplicationLogsViewMode +import ch.protonmail.android.mailcommon.domain.model.BasicContactInfo +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.domain.model.encode +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.DraftActionForShareKey +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.DraftMessageIdKey +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen.SerializedDraftActionKey +import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen +import ch.protonmail.android.mailcontact.presentation.contactdetails.ContactDetailsScreen.ContactDetailsContactIdKey +import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen.ContactFormBasicContactInfoKey +import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen.ContactFormContactIdKey +import ch.protonmail.android.mailcontact.presentation.contactgroupdetails.ContactGroupDetailsScreen.ContactGroupDetailsLabelIdKey +import ch.protonmail.android.mailcontact.presentation.contactgroupform.ContactGroupFormScreen.ContactGroupFormLabelIdKey +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.ConversationIdKey +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.FilterByLocationKey +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen.ScrollToMessageIdKey +import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen +import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen.INPUT_PARAMS_KEY +import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen.MESSAGE_ID_KEY +import ch.protonmail.android.maillabel.domain.model.MailLabel +import ch.protonmail.android.maillabel.presentation.folderform.FolderFormScreen.FolderFormLabelIdKey +import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen.ParentFolderListLabelIdKey +import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen.ParentFolderListParentLabelIdKey +import ch.protonmail.android.maillabel.presentation.labelform.LabelFormScreen.LabelFormLabelIdKey +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.presentation.model.ViewModePreference +import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection +import ch.protonmail.android.mailsettings.domain.model.autolock.AutoLockInsertionMode +import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.pin.AutoLockPinScreen.AutoLockPinModeKey +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen.SWIPE_DIRECTION_KEY +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.util.kotlin.serialize + +sealed class Destination(val route: String) { + + object Screen { + object Mailbox : Destination("mailbox") + + object Conversation : Destination( + "mailbox/conversation/${ConversationIdKey.wrap()}/" + + "${ScrollToMessageIdKey.wrap()}/${FilterByLocationKey.wrap()}" + ) { + operator fun invoke( + conversationId: ConversationId, + scrollToMessageId: MessageId? = null, + filterByLocation: MailLabel? = null + ) = route.replace(ConversationIdKey.wrap(), conversationId.id) + .replace(ScrollToMessageIdKey.wrap(), scrollToMessageId?.id ?: "null") + .replace(FilterByLocationKey.wrap(), filterByLocation?.id?.labelId?.id ?: "null") + } + + object Message : Destination("mailbox/message/${MESSAGE_ID_KEY.wrap()}") { + + operator fun invoke(messageId: MessageId) = route.replace(MESSAGE_ID_KEY.wrap(), messageId.id) + } + + data object EntireMessageBody : Destination( + "mailbox/message/${MESSAGE_ID_KEY.wrap()}/body/${INPUT_PARAMS_KEY.wrap()}" + ) { + + operator fun invoke( + messageId: MessageId, + shouldShowEmbeddedImages: Boolean, + shouldShowRemoteContent: Boolean, + viewModePreference: ViewModePreference + ) = route.replace(MESSAGE_ID_KEY.wrap(), messageId.id) + .replace( + INPUT_PARAMS_KEY.wrap(), + EntireMessageBodyScreen.InputParams( + shouldShowEmbeddedImages, + shouldShowRemoteContent, + viewModePreference + ).serialize() + ) + } + + object Composer : Destination("composer") + object SetMessagePassword : Destination( + "composer/setMessagePassword/${SetMessagePasswordScreen.InputParamsKey.wrap()}" + ) { + operator fun invoke(messageId: MessageId, senderEmail: SenderEmail) = route.replace( + SetMessagePasswordScreen.InputParamsKey.wrap(), + SetMessagePasswordScreen.InputParams(messageId, senderEmail).serialize() + ) + } + + object EditDraftComposer : Destination("composer/${DraftMessageIdKey.wrap()}") { + + operator fun invoke(messageId: MessageId) = route.replace(DraftMessageIdKey.wrap(), messageId.id) + } + + object ShareFileComposer : Destination("composer/share/${DraftActionForShareKey.wrap()}") { + + operator fun invoke(draftAction: DraftAction) = route.replace( + DraftActionForShareKey.wrap(), + draftAction.serialize() + ) + } + + object MessageActionComposer : Destination("composer/action/${SerializedDraftActionKey.wrap()}") { + + operator fun invoke(action: DraftAction) = + route.replace(SerializedDraftActionKey.wrap(), action.serialize()) + } + + object Settings : Destination("settings") + object AccountSettings : Destination("settings/account") + object AlternativeRoutingSettings : Destination("settings/alternativeRouting") + object AutoLockSettings : Destination("settings/autolock") + object AutoLockPinScreen : Destination("settings/autolock/pin/${AutoLockPinModeKey.wrap()}") { + + operator fun invoke(mode: AutoLockInsertionMode) = + route.replace(AutoLockPinModeKey.wrap(), mode.serialize()) + } + + object CombinedContactsSettings : Destination("settings/combinedContacts") + object ConversationModeSettings : Destination("settings/account/conversationMode") + object AutoDeleteSettings : Destination("settings/account/autoDelete") + object DefaultEmailSettings : Destination("settings/account/defaultEmail") + object DisplayNameSettings : Destination("settings/account/displayName") + object PrivacySettings : Destination("settings/account/privacy") + object LanguageSettings : Destination("settings/appLanguage") + object CustomizeToolbar : Destination("settings/customizeToolbar") + object SwipeActionsSettings : Destination("settings/swipeActions") + object EditSwipeActionSettings : Destination("settings/swipeActions/edit/${SWIPE_DIRECTION_KEY.wrap()}") { + + operator fun invoke(direction: SwipeActionDirection) = + route.replace(SWIPE_DIRECTION_KEY.wrap(), direction.name) + } + + object ThemeSettings : Destination("settings/theme") + object Notifications : Destination("settings/notifications") + object ApplicationLogs : Destination("settings/applicationLogs") + object ApplicationLogsView : Destination("settings/applicationLogs/view/${ApplicationLogsViewMode.wrap()}") { + operator fun invoke(item: ApplicationLogsViewItemMode) = + route.replace(ApplicationLogsViewMode.wrap(), item.serialize()) + } + object DeepLinksHandler : Destination("deepLinksHandler") + object LabelList : Destination("labelList") + object CreateLabel : Destination("labelForm") + object EditLabel : Destination("labelForm/${LabelFormLabelIdKey.wrap()}") { + + operator fun invoke(labelId: LabelId) = route.replace(LabelFormLabelIdKey.wrap(), labelId.id) + } + + object FolderList : Destination("folderList") + object CreateFolder : Destination("folderForm") + object EditFolder : Destination("folderForm/${FolderFormLabelIdKey.wrap()}") { + + operator fun invoke(labelId: LabelId) = route.replace(FolderFormLabelIdKey.wrap(), labelId.id) + } + + object ParentFolderList : Destination( + "parentFolderList/${ParentFolderListLabelIdKey.wrap()}/${ParentFolderListParentLabelIdKey.wrap()}" + ) { + + operator fun invoke(labelId: LabelId?, parentLabelId: LabelId?) = run { + route.replace( + ParentFolderListLabelIdKey.wrap(), labelId?.id ?: "null" + ).replace( + ParentFolderListParentLabelIdKey.wrap(), parentLabelId?.id ?: "null" + ) + } + } + + object Contacts : Destination("contacts") + object ContactDetails : Destination("contacts/contact/${ContactDetailsContactIdKey.wrap()}") { + + operator fun invoke(contactId: ContactId) = route.replace(ContactDetailsContactIdKey.wrap(), contactId.id) + } + + object CreateContact : Destination("contacts/contact/form") + object AddContact : Destination( + "contacts/addContact/${ContactFormBasicContactInfoKey.wrap()}/form" + ) { + operator fun invoke(contactInfo: BasicContactInfo): String { + return route.replace( + ContactFormBasicContactInfoKey.wrap(), + contactInfo.encode().serialize() + ) + } + } + + object EditContact : Destination("contacts/contact/${ContactFormContactIdKey.wrap()}/form") { + + operator fun invoke(contactId: ContactId) = route.replace(ContactFormContactIdKey.wrap(), contactId.id) + } + + object ContactGroupDetails : Destination("contacts/group/${ContactGroupDetailsLabelIdKey.wrap()}") { + + operator fun invoke(labelId: LabelId) = route.replace(ContactGroupDetailsLabelIdKey.wrap(), labelId.id) + } + + object CreateContactGroup : Destination("contacts/group/form") + object EditContactGroup : Destination("contacts/group/${ContactGroupFormLabelIdKey.wrap()}/form") { + + operator fun invoke(labelId: LabelId) = route.replace(ContactGroupFormLabelIdKey.wrap(), labelId.id) + } + + object ManageMembers : Destination("contacts/group/manageMembers") + + object ContactSearch : Destination("contacts/search") + + object Onboarding { + data object MainScreen : Destination("onboarding/main") + data object Upselling : Destination("onboarding/upselling") + } + + object PostSubscription : Destination("postSubscription") + + object Upselling { + data object StandaloneMailbox : Destination("upselling/standalone/mailbox") + data object StandaloneMailboxPromo : Destination("upselling/standalone/mailboxPromo") + data object StandaloneNavbar : Destination("upselling/standalone/navbar") + } + } + + object Dialog { + object SignOut : Destination("signout/${USER_ID_KEY.wrap()}") { + + operator fun invoke(userId: UserId?) = route.replace(USER_ID_KEY.wrap(), userId?.id ?: " ") + } + + object RemoveAccount : Destination("remove/${USER_ID_KEY.wrap()}") { + + operator fun invoke(userId: UserId?) = route.replace(USER_ID_KEY.wrap(), userId?.id ?: " ") + } + } +} + +/** + * Wrap a key in the format required by the Navigation framework: `{key_name}` + */ +private fun String.wrap() = "{$this}" + diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt new file mode 100644 index 0000000000..b3e8f6ec2f --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/HomeState.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.model + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState +import me.proton.core.network.domain.NetworkStatus + +data class HomeState( + val notificationPermissionDialogState: NotificationPermissionDialogState, + val networkStatusEffect: Effect, + val messageSendingStatusEffect: Effect, + val navigateToEffect: Effect, + val startedFromLauncher: Boolean +) { + + companion object { + + val Initial = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.empty(), + messageSendingStatusEffect = Effect.empty(), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt new file mode 100644 index 0000000000..fbe3f09948 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/LauncherState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.model + +enum class LauncherState { Processing, AccountNeeded, PrimaryExist, StepNeeded } diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt new file mode 100644 index 0000000000..9b1f1209ad --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/OnboardingEligibilityState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.model + +internal sealed interface OnboardingEligibilityState { + data object Required : OnboardingEligibilityState + data object NotRequired : OnboardingEligibilityState + data object Loading : OnboardingEligibilityState +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt new file mode 100644 index 0000000000..95e646cc93 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/model/SavedStateKey.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.model + +sealed class SavedStateKey(val key: String) { + + object CurrentParentFolderId : SavedStateKey("current_parent_folder_id") + object SelectedContactEmailIds : SavedStateKey("selected_contacts_email_ids") +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt new file mode 100644 index 0000000000..50439302bc --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/Onboarding.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.onboarding + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import ch.protonmail.android.navigation.listener.withDestinationChangedObservableEffect +import ch.protonmail.android.navigation.model.Destination +import ch.protonmail.android.navigation.route.addOnboarding +import ch.protonmail.android.navigation.route.addOnboardingUpselling +import io.sentry.compose.withSentryObservableEffect + +@Composable +fun Onboarding() { + val navController = rememberNavController() + .withSentryObservableEffect() + .withDestinationChangedObservableEffect() + val onboardingStepViewModel = hiltViewModel() + + val exitAction = remember { + { + onboardingStepViewModel.submit(OnboardingStepAction.MarkOnboardingComplete) + } + } + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = Destination.Screen.Onboarding.MainScreen.route + ) { + addOnboarding(navController, exitAction) + addOnboardingUpselling(exitAction) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt new file mode 100644 index 0000000000..62966b8d66 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/onboarding/OnboardingStepViewModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailonboarding.domain.usecase.SaveOnboarding +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class OnboardingStepViewModel @Inject constructor( + private val saveOnboarding: SaveOnboarding +) : ViewModel() { + + fun submit(action: OnboardingStepAction) { + viewModelScope.launch { + when (action) { + OnboardingStepAction.MarkOnboardingComplete -> saveOnboarding(display = false) + } + } + } +} + +internal sealed interface OnboardingStepAction { + data object MarkOnboardingComplete : OnboardingStepAction +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt new file mode 100644 index 0000000000..387b494778 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/DeepLinkRoutes.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.route + +import android.content.Context +import android.os.Bundle +import android.widget.Toast +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import ch.protonmail.android.R +import ch.protonmail.android.mailnotifications.domain.NotificationInteraction +import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper +import ch.protonmail.android.mailnotifications.domain.resolveNotificationInteraction +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel +import ch.protonmail.android.navigation.model.Destination + +@Suppress("ComplexMethod", "LongMethod") +internal fun NavGraphBuilder.addDeepLinkHandler(navController: NavHostController) { + composable( + route = Destination.Screen.DeepLinksHandler.route, + deepLinks = listOf( + navDeepLink { uriPattern = NotificationsDeepLinkHelper.DeepLinkMessageTemplate }, + navDeepLink { uriPattern = NotificationsDeepLinkHelper.DeepLinkMessageGroupTemplate } + ) + ) { + val context = LocalContext.current + val viewModel: NotificationsDeepLinksViewModel = hiltViewModel() + val state = viewModel.state.collectAsState().value + + LaunchedEffect(key1 = state) { + when (state) { + is NotificationsDeepLinksViewModel.State.Launched -> { + val interaction = resolveNotificationInteraction( + userId = it.arguments.userId, + messageId = it.arguments.messageId, + action = it.arguments.action + ) + + when (interaction) { + is NotificationInteraction.SingleTap -> { + viewModel.navigateToMessage(messageId = interaction.messageId, userId = interaction.userId) + } + + is NotificationInteraction.GroupTap -> { + viewModel.navigateToInbox(interaction.userId) + } + + NotificationInteraction.NoAction -> Unit + } + } + + is NotificationsDeepLinksViewModel.State.NavigateToInbox.ActiveUser -> { + navController.navigate(Destination.Screen.Mailbox.route) { + popUpTo(navController.graph.id) { inclusive = false } + } + } + + is NotificationsDeepLinksViewModel.State.NavigateToInbox.ActiveUserSwitched -> { + navController.navigate(Destination.Screen.Mailbox.route) { + popUpTo(navController.graph.id) { inclusive = true } + } + showUserSwitchedEmailIfRequired(context, state.email) + } + + is NotificationsDeepLinksViewModel.State.NavigateToMessageDetails -> { + navController.navigate(Destination.Screen.Message(state.messageId)) { + popUpTo(Destination.Screen.Mailbox.route) { inclusive = false } + } + showUserSwitchedEmailIfRequired(context, state.userSwitchedEmail) + } + + is NotificationsDeepLinksViewModel.State.NavigateToConversation -> { + navController.navigate(Destination.Screen.Conversation(conversationId = state.conversationId)) { + popUpTo(Destination.Screen.Mailbox.route) { inclusive = false } + } + showUserSwitchedEmailIfRequired(context, state.userSwitchedEmail) + } + } + } + } +} + +private fun showUserSwitchedEmailIfRequired(context: Context, email: String?) { + if (email.isNullOrBlank()) return + + Toast.makeText( + context, + context.getString(R.string.notification_switched_account, email), + Toast.LENGTH_LONG + ).show() +} + +private val Bundle?.messageId: String? + get() = this?.getString("messageId") + +private val Bundle?.userId: String? + get() = this?.getString("userId") + +private val Bundle?.action: String? + get() = this?.getString("action") diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt new file mode 100644 index 0000000000..5e0f6e89da --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/HomeRoutes.kt @@ -0,0 +1,648 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.route + +import android.net.Uri +import androidx.compose.material.DrawerState +import androidx.compose.runtime.livedata.observeAsState +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import ch.protonmail.android.MainActivity +import ch.protonmail.android.feature.account.RemoveAccountDialog +import ch.protonmail.android.feature.account.SignOutAccountDialog +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.presentation.extension.navigateBack +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen2 +import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen +import ch.protonmail.android.mailcontact.presentation.contactdetails.ContactDetailsScreen +import ch.protonmail.android.mailcontact.presentation.contactform.ContactFormScreen +import ch.protonmail.android.mailcontact.presentation.contactgroupdetails.ContactGroupDetailsScreen +import ch.protonmail.android.mailcontact.presentation.contactgroupform.ContactGroupFormScreen +import ch.protonmail.android.mailcontact.presentation.contactlist.ui.ContactListScreen +import ch.protonmail.android.mailcontact.presentation.contactsearch.ContactSearchScreen +import ch.protonmail.android.mailcontact.presentation.managemembers.ManageMembersScreen +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetail +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen +import ch.protonmail.android.maildetail.presentation.ui.EntireMessageBodyScreen +import ch.protonmail.android.maildetail.presentation.ui.MessageDetail +import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen +import ch.protonmail.android.maillabel.presentation.folderform.FolderFormScreen +import ch.protonmail.android.maillabel.presentation.folderlist.FolderListScreen +import ch.protonmail.android.maillabel.presentation.folderparentlist.ParentFolderListScreen +import ch.protonmail.android.maillabel.presentation.labelform.LabelFormScreen +import ch.protonmail.android.maillabel.presentation.labellist.LabelListScreen +import ch.protonmail.android.mailmailbox.domain.model.MailboxItemType +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreen +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailsettings.presentation.settings.MainSettingsScreen +import ch.protonmail.android.mailupselling.presentation.ui.postsubscription.PostSubscriptionScreen +import ch.protonmail.android.navigation.model.Destination +import ch.protonmail.android.navigation.model.Destination.Screen +import ch.protonmail.android.navigation.model.SavedStateKey +import me.proton.core.compose.navigation.get +import me.proton.core.domain.entity.UserId +import me.proton.core.util.kotlin.takeIfNotBlank + + +internal fun NavGraphBuilder.addConversationDetail(actions: ConversationDetail.Actions) { + composable(route = Destination.Screen.Conversation.route) { + ConversationDetailScreen(actions = actions) + } +} + +@Suppress("LongParameterList") +internal fun NavGraphBuilder.addMailbox( + navController: NavHostController, + drawerState: DrawerState, + showOfflineSnackbar: () -> Unit, + showNormalSnackbar: (message: String) -> Unit, + showErrorSnackbar: (String) -> Unit, + onRequestNotificationPermission: () -> Unit +) { + composable(route = Destination.Screen.Mailbox.route) { + MailboxScreen( + actions = MailboxScreen.Actions.Empty.copy( + navigateToMailboxItem = { request -> + val destination = when (request.shouldOpenInComposer) { + true -> Destination.Screen.EditDraftComposer(MessageId(request.itemId.value)) + false -> when (request.itemType) { + MailboxItemType.Message -> Destination.Screen.Message(MessageId(request.itemId.value)) + MailboxItemType.Conversation -> + Destination.Screen.Conversation( + ConversationId(request.itemId.value), + request.subItemId?.let { mailboxItemId -> + MessageId(mailboxItemId.value) + }, + request.filterByLocation + ) + } + } + navController.navigate(destination) + }, + navigateToComposer = { navController.navigate(Destination.Screen.Composer.route) }, + showOfflineSnackbar = showOfflineSnackbar, + showNormalSnackbar = showNormalSnackbar, + showErrorSnackbar = showErrorSnackbar, + onAddLabel = { navController.navigate(Destination.Screen.CreateLabel.route) }, + onAddFolder = { navController.navigate(Destination.Screen.CreateFolder.route) }, + onNavigateToStandaloneUpselling = { isPromo -> + if (isPromo) { + navController.navigate(Destination.Screen.Upselling.StandaloneMailboxPromo.route) + } else { + navController.navigate(Destination.Screen.Upselling.StandaloneMailbox.route) + } + }, + onRequestNotificationPermission = onRequestNotificationPermission, + navigateToCustomizeToolbar = { + navController.navigate(Screen.CustomizeToolbar.route) + } + ), + drawerState = drawerState + ) + } +} + +internal fun NavGraphBuilder.addMessageDetail(actions: MessageDetail.Actions) { + composable(route = Destination.Screen.Message.route) { + MessageDetailScreen(actions = actions) + } +} + +internal fun NavGraphBuilder.addEntireMessageBody( + navController: NavHostController, + onOpenMessageBodyLink: (Uri) -> Unit +) { + composable(route = Destination.Screen.EntireMessageBody.route) { + EntireMessageBodyScreen( + onBackClick = { navController.navigateBack() }, + onOpenMessageBodyLink = onOpenMessageBodyLink + ) + } +} + +internal fun NavGraphBuilder.addComposer( + navController: NavHostController, + activityActions: MainActivity.Actions, + showDraftSavedSnackbar: (messasgeId: MessageId) -> Unit, + showMessageSendingSnackbar: () -> Unit, + showMessageSendingOfflineSnackbar: () -> Unit, + showComposerV2: Boolean = false +) { + val actions = ComposerScreen.Actions( + onCloseComposerClick = navController::navigateBack, + onSetMessagePasswordClick = { messageId, senderEmail -> + navController.navigate(Destination.Screen.SetMessagePassword(messageId, senderEmail)) + }, + showDraftSavedSnackbar = showDraftSavedSnackbar, + showMessageSendingSnackbar = showMessageSendingSnackbar, + showMessageSendingOfflineSnackbar = showMessageSendingOfflineSnackbar + ) + if (showComposerV2) { + composable(route = Destination.Screen.Composer.route) { ComposerScreen2(actions) } + composable(route = Destination.Screen.EditDraftComposer.route) { ComposerScreen2(actions) } + composable(route = Destination.Screen.MessageActionComposer.route) { ComposerScreen2(actions) } + composable(route = Destination.Screen.ShareFileComposer.route) { + ComposerScreen2( + actions.copy( + onCloseComposerClick = { activityActions.finishActivity() } + ) + ) + } + } else { + composable(route = Destination.Screen.Composer.route) { ComposerScreen(actions) } + composable(route = Destination.Screen.EditDraftComposer.route) { ComposerScreen(actions) } + composable(route = Destination.Screen.MessageActionComposer.route) { ComposerScreen(actions) } + composable(route = Destination.Screen.ShareFileComposer.route) { + ComposerScreen( + actions.copy( + onCloseComposerClick = { activityActions.finishActivity() } + ) + ) + } + } +} + +internal fun NavGraphBuilder.addSignOutAccountDialog(navController: NavHostController) { + dialog(route = Destination.Dialog.SignOut.route) { + SignOutAccountDialog( + userId = it.get(SignOutAccountDialog.USER_ID_KEY)?.takeIfNotBlank()?.let(::UserId), + actions = SignOutAccountDialog.Actions( + onSignedOut = { navController.navigateBack() }, + onRemoved = { navController.navigateBack() }, + onCancelled = { navController.navigateBack() } + ) + ) + } +} + +internal fun NavGraphBuilder.addSetMessagePassword(navController: NavHostController) { + composable(route = Destination.Screen.SetMessagePassword.route) { + SetMessagePasswordScreen( + onBackClick = { + navController.navigateBack() + } + ) + } +} + +internal fun NavGraphBuilder.addRemoveAccountDialog(navController: NavHostController) { + dialog(route = Destination.Dialog.RemoveAccount.route) { + RemoveAccountDialog( + userId = it.get(RemoveAccountDialog.USER_ID_KEY)?.takeIfNotBlank()?.let(::UserId), + onRemoved = { navController.navigateBack() }, + onCancelled = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addSettings(navController: NavHostController) { + composable(route = Destination.Screen.Settings.route) { + MainSettingsScreen( + actions = MainSettingsScreen.Actions( + onAccountClick = { + navController.navigate(Destination.Screen.AccountSettings.route) + }, + onThemeClick = { + navController.navigate(Destination.Screen.ThemeSettings.route) + }, + onPushNotificationsClick = { + navController.navigate(Destination.Screen.Notifications.route) + }, + onAutoLockClick = { + navController.navigate(Destination.Screen.AutoLockSettings.route) + }, + onAlternativeRoutingClick = { + navController.navigate(Destination.Screen.AlternativeRoutingSettings.route) + }, + onAppLanguageClick = { + navController.navigate(Destination.Screen.LanguageSettings.route) + }, + onCombinedContactsClick = { + navController.navigate(Destination.Screen.CombinedContactsSettings.route) + }, + onCustomizeToolbarClick = { + navController.navigate(Destination.Screen.CustomizeToolbar.route) + }, + onSwipeActionsClick = { + navController.navigate(Destination.Screen.SwipeActionsSettings.route) + }, + onClearCacheClick = {}, + onExportLogsClick = { isInternalFeatureEnabled -> + if (isInternalFeatureEnabled) { + navController.navigate(Destination.Screen.ApplicationLogs.route) + } else { + navController.navigate( + Destination.Screen.ApplicationLogsView(ApplicationLogsViewItemMode.Events) + ) + } + }, + onBackClick = { + navController.navigateBack() + }, + onSignOut = { + navController.navigate(Destination.Dialog.SignOut(it)) + } + ) + ) + } +} + +internal fun NavGraphBuilder.addLabelList( + navController: NavHostController, + showLabelListErrorLoadingSnackbar: () -> Unit +) { + composable(route = Destination.Screen.LabelList.route) { + LabelListScreen( + actions = LabelListScreen.Actions( + onBackClick = { + navController.navigateBack() + }, + onLabelSelected = { labelId -> + navController.navigate(Destination.Screen.EditLabel(labelId)) + }, + onAddLabelClick = { + navController.navigate(Destination.Screen.CreateLabel.route) + }, + showLabelListErrorLoadingSnackbar = showLabelListErrorLoadingSnackbar + ) + ) + } +} + +internal fun NavGraphBuilder.addLabelForm( + navController: NavHostController, + showLabelSavedSnackbar: () -> Unit, + showLabelDeletedSnackbar: () -> Unit, + showUpsellingSnackbar: (String) -> Unit, + showUpsellingErrorSnackbar: (String) -> Unit +) { + val actions = LabelFormScreen.Actions.Empty.copy( + onBackClick = { + navController.navigateBack() + }, + showLabelSavedSnackbar = showLabelSavedSnackbar, + showLabelDeletedSnackbar = showLabelDeletedSnackbar, + showUpsellingSnackbar = showUpsellingSnackbar, + showUpsellingErrorSnackbar = showUpsellingErrorSnackbar + ) + composable(route = Destination.Screen.CreateLabel.route) { LabelFormScreen(actions) } + composable(route = Destination.Screen.EditLabel.route) { LabelFormScreen(actions) } +} + +internal fun NavGraphBuilder.addFolderList( + navController: NavHostController, + showErrorSnackbar: (message: String) -> Unit +) { + composable(route = Destination.Screen.FolderList.route) { + FolderListScreen( + actions = FolderListScreen.Actions( + onBackClick = { + navController.navigateBack() + }, + onFolderSelected = { labelId -> + navController.navigate(Destination.Screen.EditFolder(labelId)) + }, + onAddFolderClick = { + navController.navigate(Destination.Screen.CreateFolder.route) + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + } + ) + ) + } +} + +internal fun NavGraphBuilder.addFolderForm( + navController: NavHostController, + showSuccessSnackbar: (message: String) -> Unit, + showErrorSnackbar: (message: String) -> Unit, + showNormSnackbar: (String) -> Unit +) { + val actions = FolderFormScreen.Actions.Empty.copy( + onBackClick = { + navController.navigateBack() + }, + onFolderParentClick = { labelId, currentParentLabelId -> + navController.navigate(Destination.Screen.ParentFolderList(labelId, currentParentLabelId)) + }, + exitWithSuccessMessage = { message -> + navController.navigateBack() + showSuccessSnackbar(message) + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + }, + showUpsellingSnackbar = { showNormSnackbar(it) }, + showUpsellingErrorSnackbar = { showErrorSnackbar(it) } + ) + composable(route = Destination.Screen.CreateFolder.route) { + FolderFormScreen( + actions, + currentParentLabelId = navController.currentBackStackEntry?.savedStateHandle?.getLiveData( + SavedStateKey.CurrentParentFolderId.key + )?.observeAsState() + ) + } + composable(route = Destination.Screen.EditFolder.route) { + FolderFormScreen( + actions, + currentParentLabelId = navController.currentBackStackEntry?.savedStateHandle?.getLiveData( + SavedStateKey.CurrentParentFolderId.key + )?.observeAsState() + ) + } +} + +internal fun NavGraphBuilder.addParentFolderList( + navController: NavHostController, + showErrorSnackbar: (message: String) -> Unit +) { + val actions = ParentFolderListScreen.Actions.Empty.copy( + onBackClick = { + navController.navigateBack() + }, + onFolderSelected = { labelId -> + navController.previousBackStackEntry?.savedStateHandle?.set( + SavedStateKey.CurrentParentFolderId.key, + labelId.id + ) + navController.navigateBack() + }, + onNoneClick = { + navController.previousBackStackEntry?.savedStateHandle?.set( + SavedStateKey.CurrentParentFolderId.key, + "" + ) + navController.navigateBack() + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + } + ) + composable(route = Destination.Screen.ParentFolderList.route) { + ParentFolderListScreen(actions) + } +} + +internal fun NavGraphBuilder.addContacts( + navController: NavHostController, + showErrorSnackbar: (message: String) -> Unit, + showNormalSnackbar: (message: String) -> Unit, + showFeatureMissingSnackbar: () -> Unit +) { + composable(route = Destination.Screen.Contacts.route) { + ContactListScreen( + listActions = ContactListScreen.Actions( + onNavigateToNewContactForm = { + navController.navigate(Destination.Screen.CreateContact.route) + }, + onNavigateToNewGroupForm = { + navController.navigate(Destination.Screen.CreateContactGroup.route) + }, + onNavigateToContactSearch = { + navController.navigate(Destination.Screen.ContactSearch.route) + }, + openImportContact = { + showFeatureMissingSnackbar() + }, + onContactSelected = { contactId -> + navController.navigate(Destination.Screen.ContactDetails(contactId)) + }, + onContactGroupSelected = { labelId -> + navController.navigate(Destination.Screen.ContactGroupDetails(labelId)) + }, + onBackClick = { + navController.navigateBack() + }, + onSubscriptionUpgradeRequired = { + showNormalSnackbar(it) + }, + onNewGroupClick = { + // Defined at the inner call site. + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + } + ) + ) + } +} + +internal fun NavGraphBuilder.addContactDetails( + navController: NavHostController, + showSuccessSnackbar: (message: String) -> Unit, + showErrorSnackbar: (message: String) -> Unit, + showFeatureMissingSnackbar: () -> Unit +) { + val actions = ContactDetailsScreen.Actions.Empty.copy( + onBackClick = { navController.navigateBack() }, + exitWithSuccessMessage = { message -> + navController.navigateBack() + showSuccessSnackbar(message) + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + }, + onEditClick = { contactId -> + navController.navigate(Destination.Screen.EditContact(contactId)) + }, + showFeatureMissingSnackbar = { showFeatureMissingSnackbar() }, + navigateToComposer = { + navController.navigate(Destination.Screen.MessageActionComposer(DraftAction.ComposeToAddresses(listOf(it)))) + } + ) + composable(route = Destination.Screen.ContactDetails.route) { + ContactDetailsScreen(actions) + } +} + +internal fun NavGraphBuilder.addContactForm( + navController: NavHostController, + showSuccessSnackbar: (message: String) -> Unit, + showErrorSnackbar: (message: String) -> Unit +) { + + val actions = ContactFormScreen.Actions.Empty.copy( + onCloseClick = { + navController.navigateBack() + }, + exitWithSuccessMessage = { message -> + navController.navigateBack() + showSuccessSnackbar(message) + }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + } + ) + composable(route = Destination.Screen.CreateContact.route) { + ContactFormScreen(actions) + } + composable(route = Destination.Screen.EditContact.route) { + ContactFormScreen(actions) + } + composable(route = Destination.Screen.AddContact.route) { + ContactFormScreen(actions) + } +} + +internal fun NavGraphBuilder.addContactGroupDetails( + navController: NavHostController, + showErrorSnackbar: (message: String) -> Unit, + showNormSnackbar: (message: String) -> Unit +) { + val actions = ContactGroupDetailsScreen.Actions( + onBackClick = { navController.navigateBack() }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + }, + exitWithNormMessage = { message -> + navController.navigateBack() + showNormSnackbar(message) + }, + showErrorMessage = { message -> + showErrorSnackbar(message) + }, + onEditClick = { labelId -> + navController.navigate(Destination.Screen.EditContactGroup(labelId)) + }, + navigateToComposer = { emails -> + navController.navigate(Destination.Screen.MessageActionComposer(DraftAction.ComposeToAddresses(emails))) + } + ) + composable(route = Destination.Screen.ContactGroupDetails.route) { + ContactGroupDetailsScreen(actions) + } +} + +internal fun NavGraphBuilder.addContactGroupForm( + navController: NavHostController, + showSuccessSnackbar: (message: String) -> Unit, + showErrorSnackbar: (message: String) -> Unit, + showNormSnackbar: (message: String) -> Unit +) { + val actions = ContactGroupFormScreen.Actions( + onClose = { navController.navigateBack() }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + }, + exitWithSuccessMessage = { message -> + navController.navigateBack() + showSuccessSnackbar(message) + }, + manageMembers = { selectedContactEmailsIds -> + navController.currentBackStackEntry?.savedStateHandle?.set( + SavedStateKey.SelectedContactEmailIds.key, + selectedContactEmailsIds.map { it.id } + ) + navController.navigate(Destination.Screen.ManageMembers.route) + }, + exitToContactsWithNormMessage = { message -> + navController.popBackStack(Destination.Screen.Contacts.route, inclusive = false) + showNormSnackbar(message) + }, + showErrorMessage = { message -> + showErrorSnackbar(message) + } + ) + composable(route = Destination.Screen.CreateContactGroup.route) { + ContactGroupFormScreen( + actions, + selectedContactEmailsIds = navController.currentBackStackEntry?.savedStateHandle?.getLiveData>( + SavedStateKey.SelectedContactEmailIds.key + )?.observeAsState() + ) + } + composable(route = Destination.Screen.EditContactGroup.route) { + ContactGroupFormScreen( + actions, + selectedContactEmailsIds = navController.currentBackStackEntry?.savedStateHandle?.getLiveData>( + SavedStateKey.SelectedContactEmailIds.key + )?.observeAsState() + ) + } +} + +internal fun NavGraphBuilder.addManageMembers( + navController: NavHostController, + showErrorSnackbar: (message: String) -> Unit +) { + val actions = ManageMembersScreen.Actions( + onDone = { selectedContactEmailsIds -> + navController.previousBackStackEntry?.savedStateHandle?.set( + SavedStateKey.SelectedContactEmailIds.key, + selectedContactEmailsIds.map { it.id } + ) + navController.navigateBack() + }, + onClose = { navController.navigateBack() }, + exitWithErrorMessage = { message -> + navController.navigateBack() + showErrorSnackbar(message) + } + ) + composable(route = Destination.Screen.ManageMembers.route) { + ManageMembersScreen( + actions, + selectedContactEmailsIds = navController + .previousBackStackEntry + ?.savedStateHandle + ?.getLiveData>(SavedStateKey.SelectedContactEmailIds.key) + ?.observeAsState() + ) + } +} + +internal fun NavGraphBuilder.addContactSearch(navController: NavHostController) { + val actions = ContactSearchScreen.Actions( + onContactSelected = { contactId -> + navController.navigate(Destination.Screen.ContactDetails(contactId)) + }, + onContactGroupSelected = { labelId -> + navController.navigate(Destination.Screen.ContactGroupDetails(labelId)) + }, + onClose = { navController.navigateBack() } + ) + composable(route = Destination.Screen.ContactSearch.route) { + ContactSearchScreen( + actions + ) + } +} + +fun NavGraphBuilder.addPostSubscription(onClose: () -> Unit) { + composable(route = Destination.Screen.PostSubscription.route) { + PostSubscriptionScreen( + onClose = onClose + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt new file mode 100644 index 0000000000..136f5aad2a --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/OnboardingRoutes.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.route + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import ch.protonmail.android.mailonboarding.presentation.OnboardingScreen +import ch.protonmail.android.mailupselling.presentation.ui.onboarding.OnboardingUpsellScreen +import ch.protonmail.android.navigation.model.Destination + +fun NavGraphBuilder.addOnboarding(navController: NavHostController, exitAction: () -> Unit) { + composable(route = Destination.Screen.Onboarding.MainScreen.route) { + OnboardingScreen( + exitAction = exitAction, + onUpsellingNavigation = { + navController.navigate(Destination.Screen.Onboarding.Upselling.route) { + popUpTo(Destination.Screen.Onboarding.MainScreen.route) { inclusive = true } + } + } + ) + } +} + +fun NavGraphBuilder.addOnboardingUpselling(exitAction: () -> Unit) { + composable(route = Destination.Screen.Onboarding.Upselling.route) { + OnboardingUpsellScreen( + modifier = Modifier, + exitScreen = exitAction + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt new file mode 100644 index 0000000000..9954e1cc5c --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/SettingsRoutes.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.route + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import ch.protonmail.android.LockScreenActivity +import ch.protonmail.android.MainActivity +import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView +import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsScreen +import ch.protonmail.android.mailcommon.presentation.extension.navigateBack +import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection +import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.autodelete.AutoDeleteSettingScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.defaultaddress.ui.EditDefaultAddressScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.ui.EditAddressIdentityScreen +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingScreen +import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.AutoLockSettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.autolock.ui.pin.AutoLockPinScreen +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingScreen +import ch.protonmail.android.mailsettings.presentation.settings.customizetoolbar.CustomizeToolbarScreen +import ch.protonmail.android.mailsettings.presentation.settings.language.LanguageSettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.notifications.ui.PushNotificationsSettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.privacy.PrivacySettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen.SWIPE_DIRECTION_KEY +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceScreen +import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsScreen +import ch.protonmail.android.navigation.Launcher +import ch.protonmail.android.navigation.model.Destination.Screen +import me.proton.core.compose.navigation.require + +fun NavGraphBuilder.addAccountSettings( + navController: NavHostController, + launcherActions: Launcher.Actions, + activityActions: MainActivity.Actions +) { + composable(route = Screen.AccountSettings.route) { + AccountSettingScreen( + actions = AccountSettingScreen.Actions( + onBackClick = { navController.navigateBack() }, + onPasswordManagementClick = launcherActions.onPasswordManagement, + onRecoveryEmailClick = launcherActions.onRecoveryEmail, + onSecurityKeysClick = activityActions.openSecurityKeys, + onConversationModeClick = { navController.navigate(Screen.ConversationModeSettings.route) }, + onDefaultEmailAddressClick = { navController.navigate(Screen.DefaultEmailSettings.route) }, + onDisplayNameClick = { navController.navigate(Screen.DisplayNameSettings.route) }, + onPrivacyClick = { navController.navigate(Screen.PrivacySettings.route) }, + onLabelsClick = { navController.navigate(Screen.LabelList.route) }, + onFoldersClick = { navController.navigate(Screen.FolderList.route) }, + onAutoDeleteClick = { navController.navigate(Screen.AutoDeleteSettings.route) } + ) + ) + } +} + +internal fun NavGraphBuilder.addAlternativeRoutingSetting(navController: NavHostController) { + composable(route = Screen.AlternativeRoutingSettings.route) { + AlternativeRoutingSettingScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addCombinedContactsSetting(navController: NavHostController) { + composable(route = Screen.CombinedContactsSettings.route) { + CombinedContactsSettingScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addConversationModeSettings(navController: NavHostController) { + composable(route = Screen.ConversationModeSettings.route) { + ConversationModeSettingScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addAutoDeleteSettings(navController: NavHostController) { + composable(route = Screen.AutoDeleteSettings.route) { + AutoDeleteSettingScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addDefaultEmailSettings(navController: NavHostController) { + composable(route = Screen.DefaultEmailSettings.route) { + EditDefaultAddressScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addDisplayNameSettings(navController: NavHostController) { + composable(route = Screen.DisplayNameSettings.route) { + EditAddressIdentityScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() }, + onCloseScreen = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addPrivacySettings(navController: NavHostController) { + composable(route = Screen.PrivacySettings.route) { + PrivacySettingsScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addAutoLockSettings(navController: NavHostController) { + composable(route = Screen.AutoLockSettings.route) { + AutoLockSettingsScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() }, + onPinScreenNavigation = { navController.navigate(Screen.AutoLockPinScreen(it)) } + ) + } +} + +internal fun NavGraphBuilder.addAutoLockPinScreen( + activityActions: LockScreenActivity.Actions, + onBack: () -> Unit, + onShowSuccessSnackbar: (String) -> Unit +) { + composable(route = Screen.AutoLockPinScreen.route) { + AutoLockPinScreen( + modifier = Modifier, + onBackClick = onBack, + onShowSuccessSnackbar = onShowSuccessSnackbar, + onBiometricsClick = activityActions.showBiometricPrompt + ) + } +} + +internal fun NavGraphBuilder.addEditSwipeActionsSettings(navController: NavHostController) { + composable(route = Screen.EditSwipeActionSettings.route) { + EditSwipeActionPreferenceScreen( + modifier = Modifier, + direction = SwipeActionDirection(it.require(SWIPE_DIRECTION_KEY)), + onBack = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addLanguageSettings(navController: NavHostController) { + composable(route = Screen.LanguageSettings.route) { + LanguageSettingsScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addCustomizeToolbar(navController: NavHostController) { + composable(route = Screen.CustomizeToolbar.route) { + CustomizeToolbarScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addSwipeActionsSettings(navController: NavHostController) { + composable(route = Screen.SwipeActionsSettings.route) { + SwipeActionsPreferenceScreen( + modifier = Modifier, + actions = SwipeActionsPreferenceScreen.Actions( + onBackClick = { navController.navigateBack() }, + onChangeSwipeLeftClick = { + navController.navigate(Screen.EditSwipeActionSettings(SwipeActionDirection.LEFT)) + }, + onChangeSwipeRightClick = { + navController.navigate(Screen.EditSwipeActionSettings(SwipeActionDirection.RIGHT)) + } + ) + ) + } +} + +internal fun NavGraphBuilder.addThemeSettings(navController: NavHostController) { + composable(route = Screen.ThemeSettings.route) { + ThemeSettingsScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addNotificationsSettings(navController: NavHostController) { + composable(route = Screen.Notifications.route) { + PushNotificationsSettingsScreen( + modifier = Modifier, + onBackClick = { navController.navigateBack() } + ) + } +} + +internal fun NavGraphBuilder.addExportLogsSettings(navController: NavHostController) { + composable(route = Screen.ApplicationLogs.route) { + ApplicationLogsScreen( + onBackClick = { navController.navigateBack() }, + onViewItemClick = { navController.navigate(Screen.ApplicationLogsView(it)) } + ) + } + composable(route = Screen.ApplicationLogsView.route) { + ApplicationLogsPeekView( + onBack = { navController.navigateBack() } + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt new file mode 100644 index 0000000000..be347795bc --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/route/UpsellingRoutes.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.route + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import ch.protonmail.android.mailupselling.domain.model.UpsellingEntryPoint +import ch.protonmail.android.mailupselling.presentation.ui.screen.UpsellingScreen +import ch.protonmail.android.navigation.model.Destination + +fun NavGraphBuilder.addUpsellingRoutes(actions: UpsellingScreen.Actions) { + composable(route = Destination.Screen.Upselling.StandaloneMailbox.route) { + UpsellingScreen( + bottomSheetActions = actions, + entryPoint = UpsellingEntryPoint.Feature.Mailbox + ) + } + composable(route = Destination.Screen.Upselling.StandaloneMailboxPromo.route) { + UpsellingScreen( + bottomSheetActions = actions, + entryPoint = UpsellingEntryPoint.Feature.MailboxPromo + ) + } + composable(route = Destination.Screen.Upselling.StandaloneNavbar.route) { + UpsellingScreen( + bottomSheetActions = actions, + entryPoint = UpsellingEntryPoint.Feature.Navbar + ) + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt b/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt new file mode 100644 index 0000000000..9cbb17ee04 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/navigation/share/ShareIntentObserver.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.share + +import android.content.Intent +import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShareIntentObserver @Inject constructor() { + + private val _intentFlow = MutableStateFlow(null) + private val intentFlow: StateFlow = _intentFlow + + operator fun invoke(): Flow { + return intentFlow + .filterNotNull() + .filter { intent -> + !intent.isNotificationIntent() && intent.action in setOf( + Intent.ACTION_SEND, + Intent.ACTION_SEND_MULTIPLE, + Intent.ACTION_VIEW, + Intent.ACTION_SENDTO, + Intent.ACTION_MAIN + ) + } + .distinctUntilChanged() + } + + fun onNewIntent(intent: Intent?) { + _intentFlow.value = intent + } + + private fun Intent.isNotificationIntent(): Boolean = data?.host == NotificationsDeepLinkHelper.NotificationHost +} diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt new file mode 100644 index 0000000000..516ed1573c --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/useragent/BuildUserAgent.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent + +import javax.inject.Inject + +class BuildUserAgent @Inject constructor( + val getAppVersion: GetAppVersion, + val getAndroidVersion: GetAndroidVersion, + val getDeviceData: GetDeviceData +) { + operator fun invoke(): String { + val device = getDeviceData() + + val protonMailAppVersion = "ProtonMail/${getAppVersion()}" + val deviceSpecs = "${device.brand} ${device.model}" + val androidInfo = "Android ${getAndroidVersion()}; $deviceSpecs" + + return "$protonMailAppVersion ($androidInfo)" + } +} diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt new file mode 100644 index 0000000000..4df4dc9e15 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAndroidVersion.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent + +import android.os.Build.VERSION +import javax.inject.Inject + +class GetAndroidVersion @Inject constructor() { + operator fun invoke(): String = VERSION.RELEASE +} diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt new file mode 100644 index 0000000000..7045354825 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetAppVersion.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent + +import ch.protonmail.android.BuildConfig +import javax.inject.Inject + +class GetAppVersion @Inject constructor() { + operator fun invoke(): String = BuildConfig.VERSION_NAME +} diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt new file mode 100644 index 0000000000..3c47a757ce --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/useragent/GetDeviceData.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent + +import android.os.Build +import ch.protonmail.android.useragent.model.DeviceData +import javax.inject.Inject + +class GetDeviceData @Inject constructor() { + operator fun invoke() = DeviceData( + Build.DEVICE, + Build.BRAND, + Build.MODEL + ) +} diff --git a/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt b/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt new file mode 100644 index 0000000000..12b5f13e83 --- /dev/null +++ b/app/src/main/kotlin/ch/protonmail/android/useragent/model/DeviceData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent.model + +data class DeviceData( + val device: String, + val brand: String, + val model: String +) diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml index 2b068d1146..9889a97ad9 100644 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -1,30 +1,77 @@ - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cbf..4f7f81cdbc 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,27 @@ - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..5cce474a85 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4fc244418b..0000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index eca70cfe52..48305c504a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,24 @@ + + - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index eca70cfe52..48305c504a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,24 @@ + + - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd..0000000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba..0000000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e5..0000000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da08..0000000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe..0000000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3..0000000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f9f..0000000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f50836..0000000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427e6..0000000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37cb..0000000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 0000000000..59846606e0 --- /dev/null +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,45 @@ + + + + Cerrar sesión + ¿Está seguro de que quiere la cerrar sesión? + + No + Quitar la cuenta + Cerrará la sesión y eliminará toda la información asociada con esta cuenta. + Quitar + Cancelar + Seleccione otra cuenta + No está conectado. + Próximamente... + Borrador guardado + Descartar + Enviando mensaje… + Sin conexión, mensaje puesto en cola de envío + Mensaje enviado + Error al enviar el mensaje + Ir a Borradores + Error al cargar el archivo adjunto + Se cambió a la cuenta %s + Etiqueta guardada + Etiqueta eliminada + Error al cargar la etiqueta. Intente de nuevo más tarde. + Aplicación bloqueada + Use el código PIN en su lugar. + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..0a0ce5f764 --- /dev/null +++ b/app/src/main/res/values-be/strings.xml @@ -0,0 +1,45 @@ + + + + Выхад + Вы сапраўды хочаце выйсці? + Так + Не + Выдаліць уліковы запіс + Адбудзецца выхад і выдаленне ўсёй інфармацыі, якая звязана з гэтым уліковым запісам. + Выдаліць + Скасаваць + Выбраць іншы ўліковы запіс + Вы па-за сеткай + Функцыя неўзабаве з\'явіцца… + Чарнавік захаваны + Адхіліць + Адпраўка паведамлення… + Вы па-за сеткай, паведамленне ў чарзе на адпраўку + Паведамленне адпраўлена + Памылка адпраўкі паведамлення + Перайсці ў Чарнавікі + Памылка запампоўвання далучэння + Зменена на ўліковы запіс: %s + Метка захавана + Метка выдалена + Памылка загрузкі меткі. Паспрабуйце пазней + Праграма заблакіравана + Выкарыстаць замест гэтага PIN + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..10c80030cd --- /dev/null +++ b/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,45 @@ + + + + Tanca la sessió + Segur que desitgeu tancar la sessió? + + No + Esborra el compte + Es tancarà la sessió i s\'esborrarà tota la informació associada amb aquest compte. + Esborra + Cancel·la + Seleccioneu un altre compte + Esteu fora de línia + Aquesta característica arribarà aviat… + S\'ha desat l\'esborrany. + Descarta + Enviant missatge… + Fora de línia, missatge a la cua per enviar + S\'ha enviat el missatge. + Error enviant el missatge + Aneu a Esborranys + S\'ha produït un error en carregar l\'adjunt. + S\'ha canviat al compte %s + S\'ha desat l\'etiqueta. + S\'ha eliminat l\'etiqueta. + S\'ha produït un error en carregar l\'etiqueta. Proveu de nou més tard. + L\'aplicació està bloquejada + Utilitzeu el PIN en lloc + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..6a959cc925 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,45 @@ + + + + Odhlásit se + Opravdu se chcete odhlásit? + Ano + Ne + Odebrat účet + Odhlásíte se a odstraníte všechny informace spojené s tímto účtem. + Odebrat + Zrušit + Vybrat jiný účet + Jste v režimu offline + Funkce bude brzy k dispozici… + Koncept uložen + Zahodit + Odesílání zprávy… + Offline, zpráva je ve frontě k odeslání + Zpráva odeslána + Chyba při odesílání zprávy + Přejít do Konceptů + Chyba při nahrávání přílohy + Přepnuto na účet: %s + Štítek uložen + Štítek smazán + Chyba při načítání štítku, zkuste to prosím později + Aplikace uzamčena + Použít raději PIN + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..9b9e56b7a4 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,45 @@ + + + + Log ud + Er du sikker på, at du vil logge ud? + Ja + Nej + Fjern konto + Du logger ud og fjerner alle tilknyttede oplysninger til denne konto. + Fjern + Annuller + Vælg en anden konto + Du er offline + Funktion kommer snart… + Kladde gemt + Kassér + Sender besked… + Offline, besked er i kø til afsendelse + Besked sendt + Besked om fejlafsendelse + Gå til kladder + Fejl ved upload af vedhæftning + Skiftede til konto: %s + Etiket gemt + Etiket slettet + Indlæsningsfejl af etikette. Prøv igen senere. + App er låst + Brug i stedet pinkode + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..a2b8467766 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,45 @@ + + + + Abmelden + Bist du sicher, dass du dich abmelden möchtest? + Ja + Nein + Konto entfernen + Du wirst abgemeldet und alle Informationen, die mit diesem Konto verbunden sind, werden gelöscht. + Entfernen + Abbrechen + Anderes Konto auswählen + Du bist offline + Funktion demnächst verfügbar… + Entwurf gespeichert + Verwerfen + Nachricht wird gesendet… + Offline, die Nachricht ist in der Warteschlange für den Versand. + Nachricht gesendet + Fehler beim Senden der Nachricht + Zu den Entwürfen + Fehler beim Hochladen des Anhangs. + Zu Konto „%s“ gewechselt + Kategorie gespeichert + Kategorie gelöscht + Fehler beim Laden der Kategorie. Bitte versuche es später erneut. + App gesperrt + Stattdessen PIN verwenden + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..94b1b50f73 --- /dev/null +++ b/app/src/main/res/values-el/strings.xml @@ -0,0 +1,45 @@ + + + + Αποσύνδεση + Θέλετε σίγουρα να αποσυνδεθείτε; + Ναι + Όχι + Αφαίρεση Λογαριασμού + Πρόκειται να αποσυνδεθείτε και να αφαιρέσετε όλες τις πληροφορίες που σχετίζονται με αυτόν τον λογαριασμό. + Αφαίρεση + Ακύρωση + Επιλογή άλλου λογαριασμού + Βρίσκεστε εκτός σύνδεσης + Η δυνατότητα θα είναι διαθέσιμη σύντομα… + Το πρόχειρο αποθηκεύτηκε + Απόρριψη + Γίνεται αποστολή μηνύματος… + Εκτός σύνδεσης, το μήνυμα τέθηκε σε αναμονή για αποστολή + Το μήνυμα στάλθηκε + Σφάλμα κατά την αποστολή του μηνύματος + Μετάβαση στα Πρόχειρα + Σφάλμα κατά τη μεταφόρτωση συνημμένου + Έγινε αλλαγή σε λογαριασμό: %s + Η ετικέτα αποθηκεύτηκε + Η ετικέτα διαγράφηκε + Σφάλμα φόρτωσης ετικέτας, παρακαλούμε δοκιμάστε ξανά αργότερα + Η εφαρμογή είναι κλειδωμένη + Εναλλακτικά, χρησιμοποιήστε PIN + diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..f4909d81d3 --- /dev/null +++ b/app/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,45 @@ + + + + Cerrar sesión + ¿Estás seguro de que quieres cerrar la sesión? + + No + Quitar la cuenta + Cerrarás la sesión y eliminarás toda la información asociada con esta cuenta. + Quitar + Cancelar + Selecciona otra cuenta + No estás conectado. + Funcionalidad disponible próximamente… + Se ha guardado el borrador. + Descartar + Enviando el mensaje… + Sin conexión: El mensaje se ha añadido a la cola de envío. + Se ha enviado el mensaje. + Error al enviar el mensaje + Ir a la carpeta de borradores + Error al cargar el archivo adjunto + Se ha cambiado a la cuenta: %s + Se ha guardado la etiqueta. + Se ha eliminado la etiqueta. + Error al cargar la etiqueta. Intenta de nuevo más tarde. + Aplicación bloqueada + Usa el código PIN en su lugar. + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..011a4aeac2 --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,45 @@ + + + + Kirjaudu ulos + Haluatko varmasti kirjautua ulos? + Kyllä + En + Poista tili + Kirjaudut ulos ja kaikki tiliin liittyvät tiedot poistetaan. + Poista + Peru + Valitse toinen tili + Ei yhteyttä + Ominaisuus on tulossa pian… + Luonnos tallennettiin + Hylkää + Lähetetään viestiä… + Ei yhteyttä. Viesti merkittiin lähetettäväksi myöhemmin. + Viesti lähetettiin + Virhe lähetettäessä viestiä + Avaa luonnokset + Virhe lisättäessä liitettä + Vaihdettiin tiliin %s + Tunniste tallennettiin + Tunniste poistettiin + Virhe ladattaessa tunnistetta. Yritä myöhemmin uudelleen. + Sovellus on lukittu + Käytä sen sijaan PIN-koodia + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..2ad0c42bad --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,45 @@ + + + + Déconnexion + Voulez-vous vraiment vous déconnecter ? + Oui + Non + Retirer le compte + Vous allez vous déconnecter et retirer toutes les informations associées à ce compte. + Retirer + Annuler + Sélectionner un autre compte + Vous êtes hors ligne. + Fonctionnalité bientôt disponible… + Le brouillon a été enregistré. + Abandonner + L\'envoi du message est en cours. + Vous êtes hors ligne, le message est en file d\'attente pour l\'envoi. + Le message a été envoyé. + Une erreur s\'est produite lors de l\'envoi du message. + Déplacer vers le dossier Brouillons + La pièce jointe n\'a pas pu être chargée. + Basculé sur le compte %s + Le label a été enregistré. + Le label a été supprimé. + Une erreur s\'est produite lors du chargement du label. Veuillez réessayer plus tard. + Application verrouillée + Utiliser le code PIN à la place + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000000..04d840007e --- /dev/null +++ b/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,45 @@ + + + + साइनआउट करें + पक्का साइनआउट करें? + हां + नहीं + खाता हटाएं + आप साइन आउट करेंगे और इस खाते से जुड़ी सभी जानकारी हटा देंगे। + हटाएं + कैंसिल करें + दूसरा खाता चुनें + आप ऑफलाइन हैं + सुविधा जल्द शुरू होगी… + ड्राफ़्ट सेव किया गया + रद्द करें + संदेश भेजा जा रहा है… + आप ऑफ़लाइन हैं, ऑनलाइन होने पर संदेश भेजा जाएगा + संदेश भेजा गया + संदेश भेजने में समस्या आई + ड्राफ्ट पर जाएं + अटैचमेंट अपलोड करने में समस्या आई + इस खाते पर स्विच किया गया: %s + लेबल सहेजा गया + लेबल मिटाया गया + लेबल लोड करते समय एरर, कृपया बाद में वापस कोशिश करें + ऐप लॉक हो गया + इसके जगह पिन इस्तेमाल करें + diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..c476bc7141 --- /dev/null +++ b/app/src/main/res/values-hr/strings.xml @@ -0,0 +1,45 @@ + + + + Odjavite se + Da li ste sigurni da se želite odjaviti? + Da + Ne + Ukloni račun + Odjavit ćete se i ukloniti sve podatke povezane s ovim računom. + Ukloni + Poništi + Odaberite drugi račun + Niste na mreži + Značajka uskoro dolazi... + Skica je spremljena + Odbaci + Slanje poruke… + Izvan mreže, poruka čeka na slanje + Poruka je poslana + Pogreška pri slanju poruke + Idi u skice + Error uploading attachment + Prebačeno na račun %s + Oznaka spremljena + Oznaka izbrisana + Pogreška pri učitavanju oznake, pokušajte ponovno kasnije + Aplikacija je zaključana + Koristi PIN + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..ca86b7c914 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,45 @@ + + + + Kijelentkezés + Biztos, hogy ki szeretne jelentkezni? + Igen + Nem + Fiók eltávolítása + Kijelentkezik és eltávolítja a fiókkal kapcsolatos összes adatot. + Eltávolítás + Mégsem + Másik fiók választása + Nincs internetkapcsolat + A funkció hamarosan érkezik... + Piszkozat mentve + Elvetés + Üzenet küldése… + Offline, az üzenet sorba van állítva küldésre + Üzenet elküldve + Hiba az üzenet küldése közben + Ugrás a piszkozatokhoz + Hiba mellékletek feltöltésénél + Fiókváltás történt erre: %s + Címke mentve + Címke törölve + Hiba a címke betöltése során, próbálja újra később + Az alkalmazás zárolt + Inkább PIN-kód használata + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..68dfa3dd4b --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,45 @@ + + + + Keluar + Apakah Anda yakin ingin keluar? + Ya + Tidak + Hapus Akun + Anda akan keluar dari akun dan menghapus segala informasi yang terhubung dengan akun ini. + Hapus + Batal + Pilih akun lain + Anda sedang offline + Fitur ini akan segera hadir… + Draf disimpan + Buang + Mengirim pesan… + Luring, pesan diantrekan untuk pengiriman + Pesan terkirim + Masalah dalam mengirim pesan + Ke Draf + Kesalahan mengunggah lampiran + Anda telah beralih ke akun: \'%s + Label disimpan + Label dihapus + Terdapat kesalahan dalam memuat label, silakan coba lagi nanti + Aplikasi terkunci + Gunakan PIN sebagai gantinya + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..d615951fd1 --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,45 @@ + + + + Esci dall\'account + Sei sicuro di voler uscire da questo account? + Esci + Annulla + Rimuovi l\'account + Sei sicuro di voler rimuovere questo account e tutte le informazioni associate a esso? + Rimuovi + Annulla + Seleziona un altro account + Nessuna connessione a Internet + Funzionalità in arrivo… + Bozza salvata + Scarta + Invio del messaggio in corso + Nessuna connessione a Internet. Invio del messaggio sospeso. + Messaggio inviato + Invio del messaggio non riuscito + Vai alle bozze + Caricamento dell\'allegato non riuscito + Account attuale: %s + Etichetta salvata + Etichetta eliminata + Caricamento dell\'etichetta non riuscito + App bloccata + Usa invece il PIN + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..39199d1cce --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,45 @@ + + + + サインアウト + 本当にサインアウトしますか? + はい + いいえ + アカウントの削除 + サインアウトして、このアカウントに関連するすべての情報を削除することになります。 + 削除 + キャンセル + 別のアカウントを選択 + オフラインです + 近日公開予定です。 + 下書きを保存しました + 破棄 + メッセージを送信中… + オフライン、メッセージは送信待ち + メッセージが送信されました + メッセージ送信エラー + 下書きを開く + 添付ファイルのアップロードエラー + アカウントを %s に切り替えました + ラベルが保存されました + ラベルが削除されました + ラベルの読み込み中にエラーが発生しました。後でもう一度お試しください。 + アプリがロックされました + 代わりにPINコードを使用する + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..b6cac1bc73 --- /dev/null +++ b/app/src/main/res/values-ka/strings.xml @@ -0,0 +1,45 @@ + + + + გასვლა + დარწმუნებული ბრძანდებით, რომ გნებავთ, გახვიდეთ? + დიახ + არა + ანგარიშის წაშლა + გახვალთ და წაშლით ყველაფერს, რაც დაკავშირებულია ამ ანგარიშთან. + წაშლა + გაუქმება + აირჩიეთ სხვა ანგარიში + ქსელი გამორთულია + ფუნქცია მალე ჩაირთვება… + მონახაზი შენახულია + მოცილება + შეტყობინების გაგზავნა… + ინტერნეტის გარეშე. შეტყობინება გაგზავნის რიგშია + შეტყობინება გაიგზავნა + შეტყობინების გაგზავნის შეცდომა + მონახაზებზე გადასვლა + მიმაგრებული ფაილის ატვირთვის შეცდომა + გადაერთეთ ანგარიშზე: %s + ჭდე შენახულია + ჭდე წაშლილია + ჭდის ჩატვირთვის შეცდომა. მოგვიანებით სცადეთ + აპლიკაცია ჩაკეტილია + უმჯობესია გამოიყენოთ PIN + diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..2ee3fa5f66 --- /dev/null +++ b/app/src/main/res/values-kab/strings.xml @@ -0,0 +1,45 @@ + + + + Asenser + Titedt tebɣiḍ ad teffɣeḍ? + Ih + Ala + Kkes amiḍan + Ad teffɣeḍ syen kkes talɣut akk yeqqnen ɣer umiḍan-a. + Kkes + Sefsex + Fren amiḍan-nniḍen + Ur teqqineḍ ara + Tamahilt-a ad tili ticki… + Arewway yettusekles + Sefsex + Tuzna n yizen… + Offline, message queued for sending + Izen yettwazen + Yalla-d tuccḍa deg tuzna n yizen + Ddu ɣer Yirewwayen + Error uploading attachment + Tnekzeḍ ɣer umiḍan: %s + Label saved + Tabzimt tettwakkes + Error loading label, please try again later + Asnas yesekkeṛ + Seqdec PIN deg umḍiq + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..94c09bb5b3 --- /dev/null +++ b/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,45 @@ + + + + 로그아웃 + 정말로 로그아웃하시겠습니까? + + 아니오 + 계정 삭제 + 계정에서 로그아웃되며 계정과 관련된 모든 데이터가 삭제됩니다. + 제거 + 취소 + 다른 계정 선택 + 연결되지 않음 + 곧 출시될 기능입니다… + 임시 저장됨 + 저장 안 함 + 메시지를 보내는중… + 오프라인, 메시지 전송 대기중 + 메시지 보냄 + 메시지 전송 오류 + 임시 보관함으로 이동하기 + 첨부 파일 업로드 중 오류 + 계정 전환됨: %s + 라벨을 저장했습니다. + 라벨을 삭제했습니다. + 라벨 로드 중 오류 발생, 다시 시도하십시오. + 앱 잠김 + 대신 PIN 사용하기 + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..50320156ea --- /dev/null +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,45 @@ + + + + Logg av + Er du sikker på at du vil logge av? + Ja + Nei + Fjern konto + Du vil logges av og all informasjon forbundet med denne kontoen vil fjernes. + Fjern + Avbryt + Velg en annen konto + Du er frakoblet + Denne funksjonen kommer snart ... + Utkast lagret + Forkast + Sender melding… + Frakoblet modus, melding satt i sendekø + Melding sendt + Feil ved sending av melding + Gå til utkast + Feil ved opplasting av vedlegg + Byttet til konto %s + Etikett lagret + Etikett slettet + Feil ved lasting av etikett. Prøv igjen senere. + App låst + Bruk PIN-kode istedenfor + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index c9ceefca3f..0000000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..c92a2be8c0 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,45 @@ + + + + Afmelden + Weet u zeker dat u zich wilt uitloggen? + Ja + Nee + Account Verwijderen + U zult worden afgemeld en verwijdert alle informatie verbonden met dit account. + Verwijderen + Annuleren + Kies een ander account + U bent offline + Functie binnenkort mogelijk + Concept opgeslagen + Negeren + Bericht wordt verzonden… + Offline, bericht geplaatst in wachtrij voor verzenden + Bericht verzonden + Fout bij verzenden bericht + Ga naar Concepten + Fout bij het uploaden van bijlage + Overgeschakeld naar account %s + Label opgeslagen + Label verwijderd + Fout tijdens laden label, probeer het later opnieuw + App vergrendeld + Gebruik PIN in de plaats + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..69818586ae --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,45 @@ + + + + Wyloguj + Czy na pewno chcesz się wylogować? + Tak + Nie + Usuń konto + Zostaniesz wylogowany i wszystkie dane powiązane z tym kontem zostaną usunięte. + Usuń + Anuluj + Wybierz inne konto + Jesteś w trybie offline + Funkcja dostępna wkrótce… + Szkic został zapisany + Odrzuć + Wysyłanie wiadomości… + Brak połączenia z siecią. Wiadomość została dodana do kolejki wysyłania + Wiadomość została wysłana + Wystąpił błąd podczas wysyłania wiadomości + Przejdź do Szkiców + Wystąpił błąd podczas przesyłania załącznika + Przełączono na konto %s + Etykieta została zapisana + Etykieta została usunięta + Wystąpił błąd podczas ładowania etykiety. Spróbuj ponownie później + Aplikacja jest zablokowana + Użyj kodu PIN + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..e41efe8a32 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,45 @@ + + + + Encerrar sessão + Você tem certeza que deseja encerrar a sessão? + Sim + Não + Excluir conta + Você encerrará a sessão e removerá toda informação associada a esta conta. + Remover + Cancelar + Selecione outra conta + Você está desconectado + Este recurso chegará em breve… + Rascunho salvo + Descartar + Enviando mensagem… + Sem conexão, mensagem aguardando o envio + Mensagem enviada + Erro no envio da mensagem + Ir para Rascunhos + Erro ao enviar anexo + Conta alterada para: %s + Marcador salvo + Marcador excluído + Erro ao carregar o marcador, tente novamente mais tarde + Aplicativo bloqueado + Usar PIN + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..39aad47ca1 --- /dev/null +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,45 @@ + + + + Terminar sessão + Tem a certeza de que deseja terminar a sessão? + Sim + Não + Remover conta + Terminará a sessão e removerá toda a informação associada a esta conta. + Remover + Cancelar + Selecionar outra conta + Está offline + Funcionalidade a disponibilizar em breve… + Rascunho guardado + Descartar + A enviar a mensagem… + Sem ligação, a mensagem está na lista para ser enviada + Mensagem enviada + Erro no envio da mensagem + Ir para Rascunhos + Erro a enviar o anexo + Mudou para a conta %s + Etiqueta guardada + Etiqueta eliminada + Erro ao carregar a etiqueta. Tente outra vez mais tarde. + Aplicação bloqueada + Utilizar antes PIN + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..4ca436d959 --- /dev/null +++ b/app/src/main/res/values-ro/strings.xml @@ -0,0 +1,45 @@ + + + + Deconectare + Sigur doriți să vă deconectați? + Da + Nu + Eliminare cont + Vă veți deconecta și veți elimina toate informațiile asociate cu acest cont. + Eliminare + Anulare + Selectare alt cont + Sunteți deconectat + Funcție disponibilă în curând… + Ciornă salvată + Aruncare + Se trimite mesajul… + Deconectat, mesajul va fi trimis ulterior. + Mesaj trimis + Eroare trimitere mesaj + Accesare Ciorne + Eroare la încărcare atașament. + S-a comutat la contul: %s + Etichetă a fost salvată. + Etichetă a fost ștearsă. + Eroare la încărcarea etichetei. Reîncercați mai târziu. + Aplicație blocată + Folosiți PIN + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..0fc100c7a0 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,45 @@ + + + + Выйти + Вы уверены, что хотите выйти? + Да + Нет + Удалить аккаунт + Вы выйдете и удалите всю информацию, связанную с этим аккаунтом. + Удалить + Отменить + Выбрать другой аккаунт + Вы офлайн + Функция скоро появится… + Черновик сохранён + Не сохранять + Отправка сообщения… + Офлайн, сообщение добавлено в очередь на отправку + Сообщение отправлено + Ошибка при отправке сообщения + Перейти в Черновики + Ошибка загрузки вложения + Переключено на аккаунт: %s + Ярлык сохранён + Ярлык удалён + Ошибка загрузки ярлыка, повторите попытку позже + Приложение заблокировано + Использовать PIN-код вместо этого + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..1be6abf790 --- /dev/null +++ b/app/src/main/res/values-sk/strings.xml @@ -0,0 +1,45 @@ + + + + Odhlásiť sa + Naozaj sa chcete odhlásiť? + Áno + Nie + Odstrániť účet + Odhlásite sa a odstránite všetky informácie spojené s týmto účtom. + Odstrániť + Zrušiť + Vyberte iný účet + Ste v režime offline + Funkcia už čoskoro… + Koncept bol uložený + Zahodiť + Odosiela sa správa… + Offline, správa vo fronte na odoslanie + Správa odoslaná + Chyba pri odosielaní správy + Prejsť do zložky Koncepty + Chyba pri nahrávaní prílohy + Prepnuté na účet: %s + Štítok uložený + Štítok odstránený + Chyba pri načítaní štítku, skúste to znova neskôr, prosím + Aplikácia uzamknutá + Použiť radšej PIN + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..3f299733e1 --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,45 @@ + + + + Odjava + Ali ste prepričani, da se želite izpisati? + Da + Ne + Odstrani račun + Odjavili se boste in odstranili vse podatke, povezane s tem računom. + Odstrani + Prekliči + Izberite drug račun + Niste povezani + Funkcija bo kmalu na voljo ... + Osnutek shranjen + Zavrzi + Pošiljanje sporočila … + Ni povezave, sporočilo je uvrščeno v pošiljanje + Sporočilo poslano + Napaka pri pošiljanju sporočila + Pojdi v mapo Osnutki + Napaka pri nalaganju priponke + Preklopili ste na račun: %s + Oznaka shranjena + Oznaka izbrisana + Napaka pri nalaganju oznake, poskusite znova pozneje + Aplikacija zaklenjena + Namesto tega uporabi kodo PIN + diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..b6af19e2ee --- /dev/null +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,45 @@ + + + + Logga ut + Är du säker på att du vill logga ut? + Ja + Nej + Ta bort konto + Du kommer att logga ut och ta bort all information som är associerat till detta konto. + Ta bort + Avbryt + Välj annat konto + Du är offline + Funktion kommer snart… + Utkast sparat + Kasta + Skickar meddelande… + Offline, meddelandet i kö för att skickas + Meddelande skickat + Det gick inte att skicka meddelandet + Gå till utkast + Fel vid uppladdning av bilaga + Bytte till konto: %s + Etikett sparad + Etikett borttagen + Gick inte läsa in etikett, försök igen senare + Appen är låst + Använd PIN-kod istället + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..7a4c74f575 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,45 @@ + + + + Oturumu kapat + Oturumu kapatmak istediğinize emin misiniz? + Evet + Hayır + Hesabı kaldır + Oturumunuz kapatılacak ve bu hesapla ilişkili tüm bilgileriniz silinecek. + Kaldır + İptal + Başka bir hesap seçin + Çevrim dışısınız + Özellik yakında kullanılabilecek… + Taslak kaydedildi + Yok say + İleti gönderiliyor… + Çevrim dışı. İleti gönderilmek üzere kuyruğa alındı + İleti gönderildi + İleti gönderilirken sorun çıktı + Taslaklar kutusuna git + Ek dosya yüklenirken sorun çıktı + Geçilen hesap: %s + Etiket kaydedldi + Etiket silindi + Etiket yüklenirken sorun çıktı. Lütfen bir süre sonra yeniden deneyin + Uygulama kilitlendi + Bunun yerine PIN kodu kullanın + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..eae305a85d --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,45 @@ + + + + Вийти + Ви дійсно хочете вийти? + Так + Ні + Вилучити обліковий запис + Ви вийдете, а всі пов\'язані з цим обліковим записом дані буде вилучено. + Вилучити + Скасувати + Вибрати інший обліковий запис + Ви не в мережі + Функція невдовзі з\'явиться… + Чернетку збережено + Відхилити + Надсилання повідомлення… + Повідомлення поставлено в чергу через відсутність мережевого з\'єднання + Повідомлення надіслано + Помилка надсилання повідомлення + Перейти до Чернеток + Помилка вивантаження вкладення + Змінено на обліковий запис: %s + Мітку збережено + Мітку видалено + Помилка завантаження мітки, спробуйте знову пізніше + Програму заблоковано + Використати PIN-код натомість + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..401f020fad --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,45 @@ + + + + 登出 + 确定登出? + + + 移除账号 + 您即将退出该账户,并删除此设备上与该账户相关的所有数据。 + 移除 + 取消 + 选择其他账户 + 您处于离线状态 + 功能即将推出… + 草稿已保存 + 放弃 + 正在发送邮件… + 网络尚未连接,邮件将稍后发送 + 邮件已发送 + 发送信息时出错 + 转至草稿箱 + 上传附件时出错 + 已切换至账户:%s + 标签已保存 + 标签已删除 + 加载标签出错,请稍后再试。 + 程序已锁定 + 输入 PIN 码 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..14d0046751 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,45 @@ + + + + 登出 + 您確定要登出嗎? + + + 移除帳號 + 您將登出,與此帳號相關的所有資訊都會被移除。 + 移除 + 取消 + 選取其它帳號 + 您處於離線狀態 + 全新功能即將大展身手... + 草稿已儲存 + 放棄 + 傳送郵件訊息... + 未連線,郵件佇列等候傳送 + 郵件已傳送 + 傳送郵件時發生錯誤 + 前往草稿資料夾 + 上載附件出現錯誤 + 已切換至帳號: %s + 已儲存標籤 + 已刪除標籤 + 載入標籤時發生錯誤,請稍後再試。 + 應用程式已鎖定 + 改為使用 PIN 碼 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index f8c6127d32..0000000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml new file mode 100644 index 0000000000..272a73990c --- /dev/null +++ b/app/src/main/res/values/config.xml @@ -0,0 +1,13 @@ + + + protonmail + true + false + true + false + true + + false + 2 + true + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cc13ad03c..304c01ae0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,46 @@ + + - ProtonMail - \ No newline at end of file + Proton Mail + Sign out + Are you sure you want to sign out? + Yes + No + Remove Account + You will sign out and remove all information associated with this account. + Remove + Cancel + Select another account + You are offline + Feature coming soon… + Draft saved + Discard + Sending message… + Offline, message queued for sending + Message sent + Error sending message + Go to Drafts + Error uploading attachment + Switched to account %s + Label saved + Label deleted + Error loading label, please try again later + App locked + Use PIN instead + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 0f1e883838..0000000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/cache_logs_file_paths.xml b/app/src/main/res/xml/cache_logs_file_paths.xml new file mode 100644 index 0000000000..bcacceb763 --- /dev/null +++ b/app/src/main/res/xml/cache_logs_file_paths.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt b/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt deleted file mode 100644 index 0363a86ce7..0000000000 --- a/app/src/test/java/ch/protonmail/android/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ch.protonmail.android - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt b/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt new file mode 100644 index 0000000000..305f8025a4 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/di/FeatureFlagModuleTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.di + +import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +class FeatureFlagModuleTest(private val testInput: TestInput) { + + @Test + fun `should provide the correct defaults`() = with(testInput) { + // When + val actualDefaults = FeatureFlagModule.provideDefaultMailFeatureFlags() + + // Then + assertEquals(expectedDefaults, actualDefaults) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + TestInput( + buildFlavor = "dev", + buildDebug = true, + expectedDefaultsMap = mapOf( + MailFeatureId.ConversationMode to true, + MailFeatureId.RatingBooster to false + ) + ), + TestInput( + buildFlavor = "alpha", + buildDebug = true, + expectedDefaultsMap = mapOf( + MailFeatureId.ConversationMode to true, + MailFeatureId.RatingBooster to false + ) + ), + TestInput( + buildFlavor = "prod", + buildDebug = false, + expectedDefaultsMap = mapOf( + MailFeatureId.ConversationMode to true, + MailFeatureId.RatingBooster to false + ) + ) + ) + } + + data class TestInput( + val buildFlavor: String, + val buildDebug: Boolean, + val expectedDefaultsMap: Map + ) { + val expectedDefaults = MailFeatureDefaults(expectedDefaultsMap) + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt new file mode 100644 index 0000000000..e9ed9fff77 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/feature/account/SignOutAccountViewModelTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.account + +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.Runs +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.test.runTest +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.test.kotlin.TestDispatcherProvider +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class SignOutAccountViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(TestDispatcherProvider().Main) + + private val accountManager = mockk(relaxUnitFun = true) + private val enqueuer = mockk() + private val viewModel = SignOutAccountViewModel(accountManager, enqueuer) + + @Before + fun setup() { + every { accountManager.getPrimaryUserId() } returns flowOf(BaseUserId) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `when initialized emits initial state`() = runTest { + // When + val actual = viewModel.state.take(1).first() + + // Then + assertEquals(SignOutAccountViewModel.State.Initial, actual) + } + + @Test + fun `when sign out is called emits signing out and then signed out when completed`() = runTest { + // Given + every { enqueuer.cancelAllWork(BaseUserId) } just Runs + + // When + viewModel.signOut() + + // Then + viewModel.state.test { + assertEquals(SignOutAccountViewModel.State.Initial, awaitItem()) + assertEquals(SignOutAccountViewModel.State.SigningOut, awaitItem()) + assertEquals(SignOutAccountViewModel.State.SignedOut, awaitItem()) + + coVerify { accountManager.disableAccount(BaseUserId) } + coVerify(exactly = 0) { accountManager.removeAccount(any()) } + } + } + + @Test + fun `when sign out is called cancel all work related to this user`() = runTest { + // Given + every { enqueuer.cancelAllWork(BaseUserId) } just Runs + + // When + viewModel.signOut(BaseUserId) + + // Then + viewModel.state.test { + assertEquals(SignOutAccountViewModel.State.Initial, awaitItem()) + assertEquals(SignOutAccountViewModel.State.SigningOut, awaitItem()) + assertEquals(SignOutAccountViewModel.State.SignedOut, awaitItem()) + + coVerify { + enqueuer.cancelAllWork(BaseUserId) + accountManager.disableAccount(BaseUserId) + } + coVerify(exactly = 0) { accountManager.removeAccount(any()) } + } + } + + private companion object { + + val BaseUserId = UserIdTestData.Primary + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt new file mode 100644 index 0000000000..bf9675a08c --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/feature/alternativerouting/HasAlternativeRoutingTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.feature.alternativerouting + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.PreferencesError +import ch.protonmail.android.mailsettings.domain.model.AlternativeRoutingPreference +import ch.protonmail.android.mailsettings.domain.repository.AlternativeRoutingRepository +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class HasAlternativeRoutingTest { + + private val alternativeRoutingRepository = mockk() + + private lateinit var hasAlternativeRouting: HasAlternativeRouting + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + + hasAlternativeRouting = HasAlternativeRouting( + alternativeRoutingRepository, + TestScope() + ) + } + + @Test + fun `emits true alternative routing preference as initial state`() = runTest { + // Given + every { alternativeRoutingRepository.observe() } returns flowOf() + // When + hasAlternativeRouting.invoke().test { + + // Then + assertEquals(AlternativeRoutingPreference(true), awaitItem()) + } + } + + @Test + fun `emits alternative routing preference from repository when repository emits`() = runTest { + // Given + every { alternativeRoutingRepository.observe() } returns flowOf( + AlternativeRoutingPreference(false).right() + ) + // When + hasAlternativeRouting.invoke().test { + awaitItem() // Intial state + // Then + assertEquals(AlternativeRoutingPreference(false), awaitItem()) + } + } + + @Test + fun `emits alternative routing preference initial value when an error happens`() = runTest { + // Given + every { alternativeRoutingRepository.observe() } returns flowOf( + PreferencesError.left() + ) + // When + hasAlternativeRouting.invoke().test { + // Then + assertEquals(AlternativeRoutingPreference(true), awaitItem()) + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt b/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt new file mode 100644 index 0000000000..4d72f5acaf --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/feature/postsubscription/ObservePostSubscriptionTest.kt @@ -0,0 +1,204 @@ +package ch.protonmail.android.feature.postsubscription + +import androidx.appcompat.app.AppCompatActivity +import ch.protonmail.android.mailcommon.domain.sample.UserSample +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser +import ch.protonmail.android.mailupselling.domain.model.UserUpgradeState +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.featureflag.domain.entity.FeatureFlag +import me.proton.core.featureflag.domain.entity.FeatureId +import me.proton.core.featureflag.domain.entity.Scope +import me.proton.core.user.domain.entity.User +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class ObservePostSubscriptionTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val observePostSubscriptionFlowEnabled = mockk { + every { this@mockk.invoke(FreeUser.userId) } returns flowOf( + FeatureFlag( + userId = UserIdTestData.userId, + featureId = FeatureId(""), + scope = Scope.Unleash, + defaultValue = false, + value = true + ) + ) + } + private val observePrimaryUser = mockk() + private val userUpgradeState = mockk() + + private val observePostSubscription = ObservePostSubscription( + observePostSubscriptionFlowEnabled = observePostSubscriptionFlowEnabled, + observePrimaryUser = observePrimaryUser, + userUpgradeState = userUpgradeState + ) + + private val mockActivity = mockk(relaxUnitFun = true) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `upon a paid user, cancel further calls`() = runTest { + // Given + val activity = mockActivity + expectPaidUser() + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 0) { observePostSubscriptionFlowEnabled(any()) } + + } + + @Test + fun `upon a free user without valid purchases, do not show post-sub activity`() = runTest { + // Given + val activity = mockActivity + expectFreeUser() + expectUpgradeCheckStates(flowOf(UserUpgradeState.UserUpgradeCheckState.Completed)) + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) } + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun `upon a free user with valid purchases for Mail Plus, show post-sub activity`() = runTest { + // Given + val activity = mockActivity + expectFreeUser() + expectUpgradeCheckStates( + flowOf( + UserUpgradeState.UserUpgradeCheckState.Pending, + UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(MailPlusPlanName)) + ) + ) + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) } + verify(exactly = 1) { activity.startActivity(any()) } + } + + @Test + fun `upon a free user with valid purchases for a different plan, don't show post-sub activity`() = runTest { + // Given + val activity = mockActivity + expectFreeUser() + expectUpgradeCheckStates( + flowOf( + UserUpgradeState.UserUpgradeCheckState.Pending, + UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(OtherPlanName)) + ) + ) + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) } + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun `upon a free user with pending purchases but no ack, don't show post-sub activity`() = runTest { + // Given + val activity = mockActivity + expectFreeUser() + expectUpgradeCheckStates( + flowOf( + UserUpgradeState.UserUpgradeCheckState.Pending, + UserUpgradeState.UserUpgradeCheckState.Completed + ) + ) + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) } + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun `upon a free user with pending purchases, show post-sub activity upon a new paid user emission`() = runTest { + // Given + val activity = mockActivity + expectUsersFlow( + flow { + emit(FreeUser) + delay(2000) + emit(PaidUser) + } + ) + expectUpgradeCheckStates( + flow { + emit(UserUpgradeState.UserUpgradeCheckState.Pending) + emit(UserUpgradeState.UserUpgradeCheckState.CompletedWithUpgrade(listOf(MailPlusPlanName))) + } + ) + + // When + observePostSubscription.start(activity) + + // Then + coVerify(exactly = 1) { observePostSubscriptionFlowEnabled(any()) } + verify(exactly = 1) { activity.startActivity(any()) } + } + + private fun expectFreeUser() { + every { observePrimaryUser() } returns flowOf(FreeUser) + } + + private fun expectPaidUser() { + every { observePrimaryUser() } returns flowOf(PaidUser) + } + + private fun expectUsersFlow(flow: Flow) { + every { observePrimaryUser() } returns flow + } + + private fun expectUpgradeCheckStates(flow: Flow) { + coEvery { userUpgradeState.userUpgradeCheckState } coAnswers { flow } + } + + companion object { + + private const val OtherPlanName = "plan-123" + private const val MailPlusPlanName = "mail2022" + + private val PaidUser = UserSample.Primary.copy(subscribed = 1) + private val FreeUser = UserSample.Primary.copy(subscribed = 0) + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt b/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt new file mode 100644 index 0000000000..9cef67783b --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/initializer/featureflag/RefreshRatingBoosterFeatureFlagsTest.kt @@ -0,0 +1,66 @@ +package ch.protonmail.android.initializer.featureflag + +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import ch.protonmail.android.mailcommon.domain.sample.AccountSample +import ch.protonmail.android.testdata.AccountTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureFlag +import me.proton.core.test.kotlin.TestCoroutineScopeProvider +import me.proton.core.test.kotlin.TestDispatcherProvider +import kotlin.test.Test + +class RefreshRatingBoosterFeatureFlagsTest { + + private val dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher()) + private val scopeProvider = TestCoroutineScopeProvider(dispatcherProvider) + + private val accountManager = mockk() + private val featureFlagManager = mockk() + + private val refreshRatingBoosterFeatureFlags = RefreshRatingBoosterFeatureFlags( + accountManager, + scopeProvider, + featureFlagManager + ) + + @Test + fun `should refresh rating booster feature flags for all users when the use case is called`() { + // Given + coEvery { + accountManager.getAccounts() + } returns flowOf(listOf(AccountTestData.readyAccount, AccountSample.Primary)) + coEvery { + featureFlagManager.getOrDefault( + userId = any(), + featureId = MailFeatureId.RatingBooster.id, + default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false), + refresh = true + ) + } returns FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false) + + // When + refreshRatingBoosterFeatureFlags() + + // Then + verifyRatingBoosterFeatureFlagRefreshedForUser(AccountTestData.readyAccount.userId) + verifyRatingBoosterFeatureFlagRefreshedForUser(AccountSample.Primary.userId) + } + + private fun verifyRatingBoosterFeatureFlagRefreshedForUser(userId: UserId) { + coVerify { + featureFlagManager.getOrDefault( + userId = userId, + featureId = MailFeatureId.RatingBooster.id, + default = FeatureFlag.default(MailFeatureId.RatingBooster.id.id, false), + refresh = true + ) + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt new file mode 100644 index 0000000000..f0459aa964 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/logging/SentryUserObserverTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.logging + +import java.util.UUID +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import io.sentry.Sentry +import io.sentry.protocol.User +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.test.kotlin.TestCoroutineScopeProvider +import me.proton.core.test.kotlin.TestDispatcherProvider +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SentryUserObserverTest { + + private val accountManager = mockk { + every { this@mockk.getPrimaryUserId() } returns flowOf(UserIdTestData.userId) + } + + private lateinit var sentryUserObserver: SentryUserObserver + + @Before + fun setUp() { + Dispatchers.setMain(TestDispatcherProvider().Main) + mockkStatic(Sentry::class) + sentryUserObserver = SentryUserObserver( + scopeProvider = TestCoroutineScopeProvider(), + accountManager = accountManager + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkStatic(Sentry::class) + } + + @Test + fun `register userId in Sentry for valid primary account`() = runTest { + // When + sentryUserObserver.start().join() + // Then + val sentryUserSlot = slot() + verify { Sentry.setUser(capture(sentryUserSlot)) } + assertEquals(UserIdTestData.userId.id, sentryUserSlot.captured.id) + } + + @Test + fun `register random UUID in Sentry when no primary account available`() = runTest { + // Given + every { accountManager.getPrimaryUserId() } returns flowOf(null) + // When + sentryUserObserver.start().join() + // Then + val sentryUserSlot = slot() + verify { Sentry.setUser(capture(sentryUserSlot)) } + val actual = UUID.fromString(sentryUserSlot.captured.id) + assertTrue(actual.toString().isNotBlank()) + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt b/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt new file mode 100644 index 0000000000..1bcdd0aee2 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/mailmessage/presentation/mapper/AttachmentUiModelMapperTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailmessage.presentation.mapper + +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.presentation.sample.AttachmentUiModelSample +import kotlin.test.Test +import kotlin.test.assertEquals + +@Deprecated("Part of Composer V1, to be replaced with AttachmentUiModelMapper2Test") +class AttachmentUiModelMapperTest { + + private val attachmentUiModelMapper = AttachmentUiModelMapper() + + @Test + fun `should map pdf message attachment with application pdf mime type to a ui model`() { + // When + val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.invoice) + + // Then + val expected = AttachmentUiModelSample.invoice + assertEquals(expected, actual) + } + + @Test + fun `should map message attachment with application doc mime type to a ui model`() { + // When + val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.document) + + // Then + val expected = AttachmentUiModelSample.document + assertEquals(expected, actual) + } + + @Test + fun `should map message attachment with multiple dots in the name to a ui model`() { + // When + val actual = attachmentUiModelMapper.toUiModel(MessageAttachmentSample.documentWithMultipleDots) + + // Then + val expected = AttachmentUiModelSample.documentWithMultipleDots + assertEquals(expected, actual) + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt new file mode 100644 index 0000000000..fb85cd020d --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/HomeViewModelTest.kt @@ -0,0 +1,547 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import android.content.Intent +import android.net.Uri +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.data.file.getShareInfo +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailcommon.domain.sample.UserSample +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUser +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.mailcomposer.domain.usecase.DiscardDraft +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveSendingMessagesStatus +import ch.protonmail.android.mailcomposer.domain.usecase.ResetSendingMessagesStatus +import ch.protonmail.android.maillabel.domain.SelectedMailLabelId +import ch.protonmail.android.mailmailbox.domain.usecase.RecordMailboxScreenView +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailnotifications.domain.model.telemetry.NotificationPermissionTelemetryEventType +import ch.protonmail.android.mailnotifications.domain.usecase.SavePermissionDialogTimestamp +import ch.protonmail.android.mailnotifications.domain.usecase.SaveShouldStopShowingPermissionDialog +import ch.protonmail.android.mailnotifications.domain.usecase.ShouldShowNotificationPermissionDialog +import ch.protonmail.android.mailnotifications.domain.usecase.TrackNotificationPermissionTelemetryEvent +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogType +import ch.protonmail.android.mailsettings.domain.usecase.autolock.ShouldPresentPinInsertionScreen +import ch.protonmail.android.navigation.model.HomeState +import ch.protonmail.android.navigation.share.ShareIntentObserver +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.network.domain.NetworkManager +import me.proton.core.network.domain.NetworkStatus +import me.proton.core.user.domain.entity.User +import org.junit.Assert.assertNull +import javax.inject.Provider +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class HomeViewModelTest { + + private val user = UserSample.Primary + + private val networkManager = mockk() + + private val observePrimaryUserMock = mockk { + every { this@mockk() } returns MutableStateFlow(user) + } + + private val observeSendingMessagesStatus = mockk { + every { this@mockk.invoke(any()) } returns flowOf(MessageSendingStatus.None) + } + + private val recordMailboxScreenView = mockk(relaxUnitFun = true) + + private val resetSendingMessageStatus = mockk(relaxUnitFun = true) + + private val selectedMailLabelId = mockk(relaxUnitFun = true) + + private val shouldPresentPinInsertionScreen = mockk { + every { this@mockk.invoke() } returns flowOf(false) + } + + private val shareIntentObserver = mockk(relaxUnitFun = true) { + every { this@mockk() } returns emptyFlow() + } + + private val discardDraft = mockk(relaxUnitFun = true) + + private val shouldShowNotificationPermissionDialog = mockk { + coEvery { this@mockk(currentTimeMillis = any(), isMessageSent = false) } returns false + } + private val savePermissionDialogTimestamp = mockk(relaxUnitFun = true) + private val saveShouldStopShowingPermissionDialog = mockk( + relaxUnitFun = true + ) + private val trackNotificationPermissionTelemetry = mockk( + relaxUnitFun = true + ) + + private val isComposerV2Enabled = mockk> { + every { this@mockk.get() } returns false + } + + private val homeViewModel by lazy { + HomeViewModel( + networkManager, + observeSendingMessagesStatus, + recordMailboxScreenView, + resetSendingMessageStatus, + selectedMailLabelId, + discardDraft, + shouldShowNotificationPermissionDialog, + savePermissionDialogTimestamp, + saveShouldStopShowingPermissionDialog, + trackNotificationPermissionTelemetry, + isComposerV2Enabled.get(), + observePrimaryUserMock, + shareIntentObserver + ) + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkStatic(Uri::class) + } + + @AfterTest + fun teardown() { + unmockkAll() + unmockkStatic(Uri::class) + } + + @Test + fun `when initialized then emit initial state`() = runTest { + // Given + every { networkManager.observe() } returns emptyFlow() + + // When + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState.Initial + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `should emit a new state with started from launcher set when intent with main action is received`() = runTest { + // Given + val mainIntent = mockIntent( + action = Intent.ACTION_MAIN, + data = null + ) + every { networkManager.observe() } returns emptyFlow() + every { shareIntentObserver() } returns flowOf(mainIntent) + + // When + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState.Initial.copy( + startedFromLauncher = true + ) + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `when the status is disconnected and is still disconnected after 5 seconds then emit disconnected status`() = + runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Disconnected) + every { networkManager.networkStatus } returns NetworkStatus.Disconnected + + // When + homeViewModel.state.test { + awaitItem() + advanceUntilIdle() + val actualItem = awaitItem() + val expectedItem = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.of(NetworkStatus.Disconnected), + messageSendingStatusEffect = Effect.empty(), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `when the status is disconnected and is metered after 5 seconds then emit metered status`() = runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Disconnected) + every { networkManager.networkStatus } returns NetworkStatus.Metered + + // When + homeViewModel.state.test { + awaitItem() + advanceUntilIdle() + val actualItem = awaitItem() + val expectedItem = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.of(NetworkStatus.Metered), + messageSendingStatusEffect = Effect.empty(), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `when the status is metered then emit metered status`() = runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Metered) + + // When + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.of(NetworkStatus.Metered), + messageSendingStatusEffect = Effect.empty(), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `when observe sending message status emits Send and then None then emit only send effect`() = runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Metered) + val sendingMessageStatusFlow = MutableStateFlow(MessageSendingStatus.MessageSent) + every { observeSendingMessagesStatus(user.userId) } returns sendingMessageStatusFlow + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = true) } returns false + + // When + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.of(NetworkStatus.Metered), + messageSendingStatusEffect = Effect.of(MessageSendingStatus.MessageSent), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + sendingMessageStatusFlow.emit(MessageSendingStatus.None) + + // Then + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `when observe sending message status emits error then emit effect and reset sending messages status`() = + runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Metered) + every { observeSendingMessagesStatus(user.userId) } returns flowOf( + MessageSendingStatus.SendMessageError + ) + + // When + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState( + notificationPermissionDialogState = NotificationPermissionDialogState.Hidden, + networkStatusEffect = Effect.of(NetworkStatus.Metered), + messageSendingStatusEffect = Effect.of(MessageSendingStatus.SendMessageError), + navigateToEffect = Effect.empty(), + startedFromLauncher = false + ) + + // Then + assertEquals(expectedItem, actualItem) + coVerify { resetSendingMessageStatus(user.userId) } + } + } + + @Test + fun `when pin lock screen needs to be shown, the effect is emitted accordingly`() = runTest { + // Given + every { networkManager.observe() } returns flowOf(NetworkStatus.Unmetered) + every { shouldPresentPinInsertionScreen() } returns flowOf(true) + + // When + Then + homeViewModel.state.test { + val actualItem = awaitItem() + val expectedItem = HomeState.Initial.copy( + networkStatusEffect = Effect.of(NetworkStatus.Unmetered) + ) + assertEquals(expectedItem, actualItem) + } + } + + @Test + fun `should emit a new state with navigation effect when a share intent is received`() = runTest { + // Given + val fileUriStr = "content://media/1234" + val fileUri = mockk() + val intentShareInfo = IntentShareInfo.Empty.copy( + attachmentUris = listOf(fileUriStr) + ) + val shareIntent = mockIntent( + action = Intent.ACTION_SEND, + data = fileUri + ) + // Mock the extension function + mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt") + every { any().getShareInfo() } returns intentShareInfo + + every { networkManager.observe() } returns flowOf() + every { shouldPresentPinInsertionScreen() } returns flowOf() + every { shareIntentObserver() } returns flowOf(shareIntent) + + // When + Then + homeViewModel.state.test { + val actualItem = awaitItem() + assertNotNull(actualItem.navigateToEffect.consume()) + } + } + + @Test + fun `should not emit a new navigation state when file share info is empty`() = runTest { + // Given + val fileUri = mockk() + val shareIntent = mockIntent( + action = Intent.ACTION_VIEW, + data = fileUri + ) + // Mock the extension function + mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt") + every { any().getShareInfo() } returns IntentShareInfo.Empty + + every { networkManager.observe() } returns flowOf() + every { shouldPresentPinInsertionScreen() } returns flowOf() + every { shareIntentObserver() } returns flowOf(shareIntent) + + // When + Then + homeViewModel.state.test { + val actualItem = awaitItem() + assertNull(actualItem.navigateToEffect.consume()) + } + } + + @Test + fun `should not emit a new navigation state when activity was started from launcher`() = runTest { + // Given + val fileUriStr = "content://media/1234" + val fileUri = mockk() + val intentShareInfo = IntentShareInfo.Empty.copy( + attachmentUris = listOf(fileUriStr) + ) + val shareIntent = mockIntent( + action = Intent.ACTION_SEND, + data = fileUri + ) + val mainIntent = mockIntent( + action = Intent.ACTION_MAIN, + data = null + ) + // Mock the extension function + mockkStatic("ch.protonmail.android.mailcommon.data.file.IntentShareExtensionsKt") + every { any().getShareInfo() } returns intentShareInfo + + every { networkManager.observe() } returns flowOf() + every { shouldPresentPinInsertionScreen() } returns flowOf() + every { shareIntentObserver() } returns flowOf(mainIntent, shareIntent) + + // When + Then + homeViewModel.state.test { + val actualItem = awaitItem() + assertNull(actualItem.navigateToEffect.consume()) + } + } + + @Test + fun `should discard draft when discard draft is called`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + + every { networkManager.observe() } returns flowOf() + + // When + homeViewModel.discardDraft(messageId) + + // Then + coVerify { discardDraft(user.userId, messageId) } + } + + @Test + fun `should call use case when recording mailbox screen view count`() { + // Given + every { networkManager.observe() } returns flowOf() + + // When + homeViewModel.recordViewOfMailboxScreen() + + // Then + verify { recordMailboxScreenView() } + } + + @Test + fun `should show notification permission dialog when initializing if use case return true`() = runTest { + // Given + every { networkManager.observe() } returns flowOf() + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true + + // When + homeViewModel.state.test { + val item = awaitItem() + + // Then + val expected = NotificationPermissionDialogState.Shown( + type = NotificationPermissionDialogType.PostOnboarding + ) + assertEquals(expected, item.notificationPermissionDialogState) + coVerify { savePermissionDialogTimestamp(any()) } + verify { + trackNotificationPermissionTelemetry( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed( + NotificationPermissionDialogType.PostOnboarding + ) + ) + } + } + } + + @Test + fun `should not show notification permission dialog when initializing if use case return false`() = runTest { + // Given + every { networkManager.observe() } returns flowOf() + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns false + + // When + homeViewModel.state.test { + val item = awaitItem() + + // Then + val expected = NotificationPermissionDialogState.Hidden + assertEquals(expected, item.notificationPermissionDialogState) + verify(exactly = 0) { trackNotificationPermissionTelemetry(any()) } + } + } + + @Test + fun `should show notification permission dialog when message was sent if use case returns true`() = runTest { + // Given + every { networkManager.observe() } returns flowOf() + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns false + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = true) } returns true + every { observeSendingMessagesStatus(user.userId) } returns flowOf(MessageSendingStatus.MessageSent) + + // When + homeViewModel.state.test { + val item = awaitItem() + + // Then + val expected = NotificationPermissionDialogState.Shown( + type = NotificationPermissionDialogType.PostSending + ) + assertEquals(expected, item.notificationPermissionDialogState) + coVerify { saveShouldStopShowingPermissionDialog() } + verify { + trackNotificationPermissionTelemetry( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed( + NotificationPermissionDialogType.PostSending + ) + ) + } + } + } + + @Test + fun `should hide notification permission dialog when close method is called`() = runTest { + // Given + every { networkManager.observe() } returns flowOf() + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true + + // When + homeViewModel.state.test { + skipItems(1) + homeViewModel.closeNotificationPermissionDialog() + val item = awaitItem() + + // Then + val expected = NotificationPermissionDialogState.Hidden + assertEquals(expected, item.notificationPermissionDialogState) + } + } + + @Test + fun `should track telemetry event when the method is called`() = runTest { + // Given + every { networkManager.observe() } returns flowOf() + coEvery { shouldShowNotificationPermissionDialog(any(), isMessageSent = false) } returns true + + // When + homeViewModel.trackTelemetryEvent( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed( + NotificationPermissionDialogType.PostOnboarding + ) + ) + + // Then + verify { + trackNotificationPermissionTelemetry( + NotificationPermissionTelemetryEventType.NotificationPermissionDialogDisplayed( + NotificationPermissionDialogType.PostOnboarding + ) + ) + } + } + + private fun mockIntent(action: String, data: Uri?): Intent { + return mockk { + every { this@mockk.action } returns action + every { this@mockk.data } returns data + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt new file mode 100644 index 0000000000..adf76735b1 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherRouterViewModelTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.PreferencesError +import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference +import ch.protonmail.android.mailonboarding.domain.usecase.ObserveOnboarding +import ch.protonmail.android.navigation.model.OnboardingEligibilityState +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class LauncherRouterViewModelTest { + + private val observeOnboarding = mockk() + + private val viewModel: LauncherRouterViewModel + get() = LauncherRouterViewModel(observeOnboarding) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun teardown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `should emit loading on observation start`() = runTest { + // Given + every { observeOnboarding() } returns flowOf() + + // When + Then + viewModel.onboardingEligibilityState.test { + assertEquals(OnboardingEligibilityState.Loading, awaitItem()) + } + } + + @Test + fun `should emit an onboarding required state when observe onboarding returns true`() = runTest { + // Given + every { observeOnboarding() } returns flowOf(OnboardingPreference(true).right()) + + // When + Then + viewModel.onboardingEligibilityState.test { + assertEquals(OnboardingEligibilityState.Required, awaitItem()) + } + } + + @Test + fun `should emit a non onboarding required state when observe onboarding returns false`() = runTest { + // Given + every { observeOnboarding() } returns flowOf(OnboardingPreference(false).right()) + + // When + Then + viewModel.onboardingEligibilityState.test { + assertEquals(OnboardingEligibilityState.NotRequired, awaitItem()) + } + } + + @Test + fun `should emit an onboarding required state when observe onboarding errors`() = runTest { + // Given + every { observeOnboarding() } returns flowOf(PreferencesError.left()) + + // When + Then + viewModel.onboardingEligibilityState.test { + assertEquals(OnboardingEligibilityState.Required, awaitItem()) + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt new file mode 100644 index 0000000000..b59985836b --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/LauncherViewModelTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import androidx.appcompat.app.AppCompatActivity +import app.cash.turbine.test +import ch.protonmail.android.mailnotifications.presentation.NotificationPermissionOrchestrator +import ch.protonmail.android.navigation.model.LauncherState +import ch.protonmail.android.testdata.AccountTestData +import ch.protonmail.android.testdata.user.UserIdTestData.userId +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.account.domain.entity.Account +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.accountmanager.presentation.AccountManagerObserver +import me.proton.core.accountmanager.presentation.observe +import me.proton.core.accountmanager.presentation.onAccountCreateAddressFailed +import me.proton.core.accountmanager.presentation.onAccountCreateAddressNeeded +import me.proton.core.accountmanager.presentation.onAccountDeviceSecretNeeded +import me.proton.core.accountmanager.presentation.onAccountTwoPassModeFailed +import me.proton.core.accountmanager.presentation.onAccountTwoPassModeNeeded +import me.proton.core.accountmanager.presentation.onSessionForceLogout +import me.proton.core.accountmanager.presentation.onSessionSecondFactorNeeded +import me.proton.core.auth.presentation.AuthOrchestrator +import me.proton.core.auth.presentation.MissingScopeObserver +import me.proton.core.humanverification.presentation.HumanVerificationManagerObserver +import me.proton.core.plan.presentation.PlansOrchestrator +import me.proton.core.report.presentation.ReportOrchestrator +import me.proton.core.usersettings.presentation.UserSettingsOrchestrator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class LauncherViewModelTest { + + private val authOrchestrator = mockk(relaxUnitFun = true) + private val plansOrchestrator = mockk(relaxUnitFun = true) + private val reportOrchestrator = mockk(relaxUnitFun = true) + private val userSettingsOrchestrator = mockk(relaxUnitFun = true) + private val notificationPermissionOrchestrator = mockk( + relaxUnitFun = true + ) + + private val accountListFlow = MutableStateFlow>(emptyList()) + private val accountManager = mockk(relaxUnitFun = true) { + every { getAccounts() } returns accountListFlow + } + + private val context = mockk { + every { lifecycle } returns mockk() + } + + private val user1Username = "username" + + private lateinit var viewModel: LauncherViewModel + + @BeforeTest + fun before() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + + viewModel = buildViewModel() + } + + @AfterTest + fun teardown() { + unmockkStatic( + AccountManagerObserver::class, + HumanVerificationManagerObserver::class, + MissingScopeObserver::class + ) + } + + @Test + fun `when no account then AccountNeeded`() = runTest { + // GIVEN + accountListFlow.emit(emptyList()) + // WHEN + viewModel.state.test { + // THEN + assertEquals(LauncherState.AccountNeeded, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when all accounts are disabled then AccountNeeded`() = runTest { + // GIVEN + accountListFlow.emit(listOf(AccountTestData.disabledAccount)) + // WHEN + viewModel.state.test { + // THEN + assertEquals(LauncherState.AccountNeeded, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when one ready account then PrimaryExist`() = runTest { + // GIVEN + accountListFlow.emit(listOf(AccountTestData.readyAccount)) + // WHEN + viewModel.state.test { + // THEN + assertEquals(LauncherState.PrimaryExist, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when adding first account`() = runTest { + // GIVEN + accountListFlow.emit(emptyList()) + // WHEN + viewModel.state.test { + // THEN + assertEquals(LauncherState.AccountNeeded, awaitItem()) + + accountListFlow.emit(listOf(AccountTestData.notReadyAccount)) + assertEquals(LauncherState.StepNeeded, awaitItem()) + + accountListFlow.emit(listOf(AccountTestData.readyAccount)) + assertEquals(LauncherState.PrimaryExist, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when adding a second account PrimaryExist state do not change`() = runTest { + // GIVEN + accountListFlow.emit(listOf(AccountTestData.readyAccount)) + // WHEN + viewModel.state.test { + // THEN + assertEquals(LauncherState.PrimaryExist, awaitItem()) + + accountListFlow.emit( + listOf(AccountTestData.readyAccount, AccountTestData.notReadyAccount) + ) + accountListFlow.emit( + listOf(AccountTestData.readyAccount, AccountTestData.readyAccount) + ) + + val events = cancelAndConsumeRemainingEvents() + assertEquals(0, events.size) + } + } + + @Test + fun `when addAccount is called, startAddAccountWorkflow`() = runTest { + // WHEN + viewModel.submit(LauncherViewModel.Action.AddAccount) + // THEN + verify { + authOrchestrator.startAddAccountWorkflow() + } + } + + @Test + fun `when signIn is called, startLoginWorkflow`() = runTest { + // WHEN + viewModel.submit(LauncherViewModel.Action.SignIn(userId = null)) + // THEN + verify { authOrchestrator.startLoginWorkflow(any()) } + } + + @Test + fun `when signIn with userId is called, startLoginWorkflow`() = runTest { + // GIVEN + every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.readyAccount) + // WHEN + viewModel.submit(LauncherViewModel.Action.SignIn(userId)) + // THEN + verify { authOrchestrator.startLoginWorkflow(user1Username) } + } + + @Test + fun `when switch is called on disabled account, startLoginWorkflow`() = runTest { + // GIVEN + every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.disabledAccount) + // WHEN + viewModel.submit(LauncherViewModel.Action.Switch(userId)) + // THEN + verify { authOrchestrator.startLoginWorkflow(user1Username) } + } + + @Test + fun `when switch is called on ready account, setPrimary`() = runTest { + // GIVEN + every { accountManager.getAccount(userId) } returns flowOf(AccountTestData.readyAccount) + // WHEN + viewModel.submit(LauncherViewModel.Action.Switch(userId)) + // THEN + coVerify { accountManager.setAsPrimary(userId) } + } + + @Test + fun `when register is called, verify AccountManagerObserver subscriptions`() = runTest { + // GIVEN + + // AccountManager + mockkStatic(AccountManager::observe) + val amObserver = mockAccountManagerObserver() + every { accountManager.observe(any(), any()) } returns amObserver + + // WHEN + viewModel.register(context) + + // THEN + // AccountManager + verify(exactly = 1) { amObserver.onAccountCreateAddressFailed(any(), any()) } + verify(exactly = 1) { amObserver.onAccountCreateAddressNeeded(any(), any()) } + verify(exactly = 1) { amObserver.onAccountTwoPassModeFailed(any(), any()) } + verify(exactly = 1) { amObserver.onAccountDeviceSecretNeeded(any(), any()) } + verify(exactly = 1) { amObserver.onAccountTwoPassModeNeeded(any(), any()) } + verify(exactly = 1) { amObserver.onSessionSecondFactorNeeded(any(), any()) } + } + + @Test + fun `when register is called userSettingsOrchestrator is registered`() = runTest { + // GIVEN + mockkStatic(AccountManager::observe) + val amObserver = mockAccountManagerObserver() + every { accountManager.observe(any(), any()) } returns amObserver + + // WHEN + viewModel.register(context) + + // THEN + verify { userSettingsOrchestrator.register(context) } + } + + @Test + fun `when passwordManagement is called then startPasswordManagementWorkflow`() = runTest { + // GIVEN + every { accountManager.getPrimaryUserId() } returns flowOf(userId) + + // WHEN + viewModel.submit(LauncherViewModel.Action.OpenPasswordManagement) + + // THEN + verify { userSettingsOrchestrator.startPasswordManagementWorkflow(userId) } + } + + @Test + fun `when change recovery email is called, correct workflow is launched`() = runTest { + // given + every { accountManager.getPrimaryUserId() } returns flowOf(userId) + + // when + viewModel.submit(LauncherViewModel.Action.OpenRecoveryEmail) + + // then + verify { userSettingsOrchestrator.startUpdateRecoveryEmailWorkflow(userId) } + } + + @Test + fun `should request notification permission when action is submitted`() { + // When + viewModel.submit(LauncherViewModel.Action.RequestNotificationPermission) + + // Then + verify { notificationPermissionOrchestrator.requestPermissionIfRequired() } + } + + private fun buildViewModel() = LauncherViewModel( + accountManager, + authOrchestrator, + notificationPermissionOrchestrator, + plansOrchestrator, + reportOrchestrator, + userSettingsOrchestrator + ) + + private fun mockAccountManagerObserver(): AccountManagerObserver { + mockkStatic(AccountManagerObserver::onAccountCreateAddressFailed) + mockkStatic(AccountManagerObserver::onAccountCreateAddressNeeded) + mockkStatic(AccountManagerObserver::onAccountTwoPassModeFailed) + mockkStatic(AccountManagerObserver::onAccountTwoPassModeNeeded) + mockkStatic(AccountManagerObserver::onAccountDeviceSecretNeeded) + mockkStatic(AccountManagerObserver::onSessionForceLogout) + mockkStatic(AccountManagerObserver::onSessionSecondFactorNeeded) + return mockk { + every { onAccountCreateAddressFailed(any(), any()) } returns this + every { onAccountCreateAddressNeeded(any(), any()) } returns this + every { onAccountTwoPassModeFailed(any(), any()) } returns this + every { onAccountTwoPassModeNeeded(any(), any()) } returns this + every { onAccountDeviceSecretNeeded(any(), any()) } returns this + every { onSessionForceLogout(any(), any()) } returns this + every { onSessionSecondFactorNeeded(any(), any()) } returns this + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt new file mode 100644 index 0000000000..1cb11b8e1f --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/OnboardingStepViewModelTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import ch.protonmail.android.mailonboarding.domain.usecase.SaveOnboarding +import ch.protonmail.android.navigation.onboarding.OnboardingStepAction +import ch.protonmail.android.navigation.onboarding.OnboardingStepViewModel +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +internal class OnboardingStepViewModelTest { + + private val saveOnboarding = mockk(relaxed = true) + private val viewModel = OnboardingStepViewModel(saveOnboarding) + + @BeforeTest + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `should call save onboarding when marking onboarding as completed`() = runTest { + // When + viewModel.submit(OnboardingStepAction.MarkOnboardingComplete) + + // Then + coVerify(exactly = 1) { saveOnboarding(false) } + confirmVerified(saveOnboarding) + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt new file mode 100644 index 0000000000..28b2e08f7a --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/ShareIntentObserverTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation + +import android.content.Intent +import app.cash.turbine.test +import ch.protonmail.android.mailnotifications.domain.NotificationsDeepLinkHelper +import ch.protonmail.android.navigation.share.ShareIntentObserver +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ShareIntentObserverTest { + + private lateinit var shareIntentObserver: ShareIntentObserver + + @Before + fun setUp() { + shareIntentObserver = ShareIntentObserver() + } + + @Test + fun `should emit intent for action send`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_SEND + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + val actual = awaitItem() + assert(actual == intent) + } + } + + @Test + fun `should emit intent for action view`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_VIEW + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + val actual = awaitItem() + assert(actual == intent) + } + } + + @Test + fun `should emit intent for action sendto`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_SENDTO + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + val actual = awaitItem() + assert(actual == intent) + } + } + + @Test + fun `should emit intent for action send multiple`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_SEND_MULTIPLE + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + val actual = awaitItem() + assert(actual == intent) + } + } + + @Test + fun `should not emit intent for an unhandled action`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_APP_ERROR + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + expectNoEvents() + } + } + + @Test + fun `should not emit share intent for a notification action`() = runTest { + // Given + val intent = mockk(relaxed = true) { + every { action } returns Intent.ACTION_VIEW + every { data?.host } returns NotificationsDeepLinkHelper.NotificationHost + } + + // When + shareIntentObserver.onNewIntent(intent) + + // Then + shareIntentObserver().test { + expectNoEvents() + } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt b/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt new file mode 100644 index 0000000000..71e53582fe --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/navigation/deeplinks/NotificationsDeepLinksViewModelTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.navigation.deeplinks + +import java.util.UUID +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.AccountSample +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailconversation.domain.repository.ConversationRepository +import ch.protonmail.android.mailconversation.domain.sample.ConversationSample +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageSample.AlphaAppQAReport +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToConversation +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToInbox +import ch.protonmail.android.navigation.deeplinks.NotificationsDeepLinksViewModel.State.NavigateToMessageDetails +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import me.proton.core.domain.type.IntEnum +import me.proton.core.mailsettings.domain.entity.MailSettings +import me.proton.core.mailsettings.domain.entity.ViewMode +import me.proton.core.mailsettings.domain.repository.MailSettingsRepository +import me.proton.core.network.domain.NetworkManager +import me.proton.core.network.domain.NetworkStatus +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class NotificationsDeepLinksViewModelTest { + + private val networkManager: NetworkManager = mockk { + coEvery { networkStatus } returns NetworkStatus.Unmetered + } + private val accountManager: AccountManager = mockk(relaxed = true) + private val messageRepository: MessageRepository = mockk() + private val conversationRepository: ConversationRepository = mockk() + private val mailSettings: MailSettings = mockk() + private val mailSettingsRepository: MailSettingsRepository = mockk { + coEvery { getMailSettings(any(), any()) } returns mailSettings + } + private val getPrimaryAddress: GetPrimaryAddress = mockk() + + @Before + fun before() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @Test + fun `Should emit navigate to inbox and cancel the group notification`() = runTest { + // Given + val viewModel = buildViewModel() + val userId = UUID.randomUUID().toString() + + // When + viewModel.navigateToInbox(userId) + + // Then + viewModel.state.test { + assertEquals(NavigateToInbox.ActiveUser, awaitItem()) + } + } + + @Test + fun `Should emit navigate to conversation details when conversation mode is enabled`() = runTest { + // Given + val messageId = UUID.randomUUID().toString() + val userId = UUID.randomUUID().toString() + coEvery { accountManager.getPrimaryUserId() } returns flowOf(UserId(userId)) + coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null) + coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf( + AlphaAppQAReport.right() + ) + coEvery { + conversationRepository.observeConversation( + UserId(userId), + AlphaAppQAReport.conversationId, + true + ) + } returns flowOf( + ConversationSample.AlphaAppFeedback.right() + ) + val viewModel = buildViewModel() + + // When + viewModel.navigateToMessage(messageId, userId) + + // Then + viewModel.state.test { + assertEquals( + NavigateToConversation(AlphaAppQAReport.conversationId), + awaitItem() + ) + } + } + + @Test + fun `Should emit navigate to message details when conversation mode is not enabled`() = runTest { + // Given + val messageId = UUID.randomUUID().toString() + val userId = UUID.randomUUID().toString() + coEvery { accountManager.getPrimaryUserId() } returns flowOf(UserId(userId)) + coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.NoConversationGrouping.value, null) + coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf( + AlphaAppQAReport.right() + ) + val viewModel = buildViewModel() + + // When + viewModel.navigateToMessage(messageId, userId) + + // Then + viewModel.state.test { + assertEquals( + NavigateToMessageDetails(AlphaAppQAReport.messageId), + awaitItem() + ) + } + } + + @Test + fun `Should emit navigate to inbox when the user is offline and taps in a message deeplink`() = runTest { + // Given + val messageId = UUID.randomUUID().toString() + val userId = UUID.randomUUID().toString() + coEvery { networkManager.networkStatus } returns NetworkStatus.Disconnected + val viewModel = buildViewModel() + + // When + viewModel.navigateToMessage(messageId, userId) + + // Then + viewModel.state.test { + assertEquals(NavigateToInbox.ActiveUser, awaitItem()) + } + } + + @Test + fun `Should navigate to the inbox if there is an error retrieving the local messages`() = runTest { + // Given + val messageId = UUID.randomUUID().toString() + val userId = UUID.randomUUID().toString() + coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.NoConversationGrouping.value, null) + coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf( + DataError.Local.Unknown.left() + ) + val viewModel = buildViewModel() + + // When + viewModel.navigateToMessage(messageId, userId) + + // Then + viewModel.state.test { + assertEquals(NavigateToInbox.ActiveUser, awaitItem()) + } + } + + @Test + fun `Should navigate to inbox if conversation mode is enabled but the conversation can not be read`() = runTest { + // Given + val messageId = UUID.randomUUID().toString() + val userId = UUID.randomUUID().toString() + coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null) + coEvery { messageRepository.observeCachedMessage(UserId(userId), MessageId(messageId)) } returns flowOf( + AlphaAppQAReport.right() + ) + coEvery { + conversationRepository.observeConversation( + UserId(userId), + AlphaAppQAReport.conversationId, + true + ) + } returns flowOf(DataError.Local.Unknown.left()) + val viewModel = buildViewModel() + + // When + viewModel.navigateToMessage(messageId, userId) + + // Then + viewModel.state.test { + assertEquals(NavigateToInbox.ActiveUser, awaitItem()) + } + } + + @Test + fun `Should switch account and emit switched for inbox notification to an active non primary account`() = runTest { + // Given + val activeAccount = AccountSample.Primary.copy(email = "test@email.com") + val notificationUserId = UserId(UUID.randomUUID().toString()) + val secondaryAccount = AccountSample.Primary.copy(userId = notificationUserId) + val viewModel = buildViewModel() + coEvery { accountManager.getPrimaryUserId() } returns flowOf(activeAccount.userId) + coEvery { accountManager.getAccounts() } returns flowOf(listOf(activeAccount, secondaryAccount)) + coEvery { getPrimaryAddress.invoke(notificationUserId) } returns UserAddressSample.PrimaryAddress.right() + + // When + viewModel.navigateToInbox(notificationUserId.id) + + // Then + viewModel.state.test { + assertEquals(NavigateToInbox.ActiveUserSwitched(secondaryAccount.email!!), awaitItem()) + coVerify { accountManager.setAsPrimary(secondaryAccount.userId) } + } + } + + @Test + fun `Should switch account and emit switched for message notification to active non primary account`() = runTest { + // Given + val activeAccount = AccountSample.Primary.copy(email = "test@email.com") + val notificationUserId = UserId(UUID.randomUUID().toString()) + val secondaryAccount = AccountSample.Primary.copy(userId = notificationUserId) + val messageId = UUID.randomUUID().toString() + val viewModel = buildViewModel() + coEvery { accountManager.getPrimaryUserId() } returns flowOf(activeAccount.userId) + coEvery { getPrimaryAddress.invoke(secondaryAccount.userId) } returns UserAddressSample.PrimaryAddress.right() + coEvery { accountManager.getAccounts() } returns flowOf(listOf(activeAccount, secondaryAccount)) + coEvery { mailSettings.viewMode } returns IntEnum(ViewMode.ConversationGrouping.value, null) + coEvery { + messageRepository.observeCachedMessage(secondaryAccount.userId, any()) + } returns flowOf(AlphaAppQAReport.right()) + coEvery { + conversationRepository.observeConversation( + secondaryAccount.userId, + AlphaAppQAReport.conversationId, + true + ) + } returns flowOf( + ConversationSample.AlphaAppFeedback.right() + ) + + // When + viewModel.navigateToMessage(messageId, secondaryAccount.userId.id) + + // Then + viewModel.state.test { + assertEquals( + NavigateToConversation( + conversationId = AlphaAppQAReport.conversationId, + userSwitchedEmail = AccountSample.Primary.email + ), + awaitItem() + ) + coVerify { accountManager.setAsPrimary(secondaryAccount.userId) } + } + } + + private fun buildViewModel() = NotificationsDeepLinksViewModel( + networkManager = networkManager, + accountManager = accountManager, + messageRepository = messageRepository, + conversationRepository = conversationRepository, + mailSettingsRepository = mailSettingsRepository, + getPrimaryAddress = getPrimaryAddress + ) +} diff --git a/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt b/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt new file mode 100644 index 0000000000..eecb8c80e4 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/outbox/OutboxObserverTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.outbox + +import ch.protonmail.android.initializer.outbox.OutboxObserver +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailmessage.data.usecase.DeleteSentMessagesFromOutbox +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.OutboxRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.test.kotlin.TestCoroutineScopeProvider +import me.proton.core.test.kotlin.TestDispatcherProvider +import kotlin.test.Test + +@ExperimentalCoroutinesApi +class OutboxObserverTest { + + private val userId = UserIdSample.Primary + private val unsentDraftItem = DraftState( + userId = userId, apiMessageId = MessageId("unsentItem01"), + messageId = MessageIdSample.AugWeatherForecast, state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ) + private val sentDraftItem = DraftState( + userId = userId, apiMessageId = MessageId("sentItem01"), + messageId = MessageIdSample.Invoice, state = DraftSyncState.Sent, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ) + + private val accountManager = mockk() + private val outboxRepository = mockk() + private val deleteSentMessagesFromOutbox = mockk() + private val draftUploadTracker = mockk() + private val dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher()) + private val scopeProvider = TestCoroutineScopeProvider(dispatcherProvider) + + private val outboxObserver = OutboxObserver( + scopeProvider, + accountManager, + outboxRepository, + deleteSentMessagesFromOutbox, + draftUploadTracker + ) + + @Test + fun `should not observe messages when userId is null`() = runTest { + // Given + coEvery { accountManager.getPrimaryUserId() } returns flowOf(null) + + // When + outboxObserver.start() + + // Then + coVerify(exactly = 0) { outboxRepository.observeAll(any()) } + } + + @Test + fun `should not call delete sent outbox messages when there are no outbox messages`() = runTest { + // Given + coEvery { accountManager.getPrimaryUserId() } returns flowOf(userId) + coEvery { outboxRepository.observeAll(any()) } returns flowOf(emptyList()) + + // When + outboxObserver.start() + + // Then + coVerify(exactly = 0) { deleteSentMessagesFromOutbox(userId, any()) } + verify(exactly = 0) { draftUploadTracker.notifySentMessages(any()) } + } + + @Test + fun `should call delete for sent outbox messages and notify draft upload tracker`() = runTest { + // Given + val outboxDraftItems = flowOf(listOf(unsentDraftItem, sentDraftItem)) + every { accountManager.getPrimaryUserId() } returns flowOf(userId) + coEvery { outboxRepository.observeAll(userId) } returns outboxDraftItems + every { draftUploadTracker.notifySentMessages(any()) } returns Unit + + // When + outboxObserver.start() + + // Then + verify(exactly = 1) { draftUploadTracker.notifySentMessages(setOf(sentDraftItem.messageId)) } + coVerify(exactly = 1) { deleteSentMessagesFromOutbox(userId, listOf(sentDraftItem)) } + coVerify(exactly = 0) { deleteSentMessagesFromOutbox(userId, listOf(unsentDraftItem)) } + } +} diff --git a/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt b/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt new file mode 100644 index 0000000000..7888ab7022 --- /dev/null +++ b/app/src/test/kotlin/ch/protonmail/android/useragent/BuildUserAgentTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.useragent + +import ch.protonmail.android.useragent.model.DeviceData +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class BuildUserAgentTest { + private val versionName = "6.0.0-alpha+fc6081a" + private val androidVersion = "12" + private val deviceModel = "model" + private val deviceBrand = "brand" + private val device = "device" + + private val getDeviceData = mockk { + every { this@mockk.invoke() } returns DeviceData(device, deviceBrand, deviceModel) + } + private val getAndroidVersion = mockk { + every { this@mockk.invoke() } returns androidVersion + } + private val getAppVersion = mockk { + every { this@mockk.invoke() } returns versionName + } + + lateinit var buildUserAgent: BuildUserAgent + + @Before + fun setUp() { + buildUserAgent = BuildUserAgent( + getAppVersion, + getAndroidVersion, + getDeviceData + ) + } + + @Test + fun `builds user agent correctly`() { + val actual = buildUserAgent() + + val protonMail = "ProtonMail/$versionName" + val android = "Android $androidVersion; $deviceBrand $deviceModel" + val expected = "$protonMail ($android)" + assertEquals(expected, actual) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt new file mode 100644 index 0000000000..06ff92f72b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/BaseTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest + +import android.content.Context +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import ch.protonmail.android.BuildConfig +import ch.protonmail.android.MainActivity +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule +import ch.protonmail.android.uitest.rule.HiltInjectRule +import ch.protonmail.android.uitest.rule.MainInitializerRule +import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule +import ch.protonmail.android.uitest.rule.SpotlightSeenRule +import dagger.hilt.android.testing.HiltAndroidRule +import kotlinx.coroutines.runBlocking +import me.proton.core.auth.domain.entity.SessionInfo +import me.proton.core.auth.domain.testing.LoginTestHelper +import me.proton.core.configuration.EnvironmentConfiguration +import me.proton.core.mailsettings.domain.repository.MailSettingsRepository +import me.proton.core.mailsettings.domain.repository.getMailSettingsOrNull +import me.proton.core.test.android.instrumented.utils.Shell.setupDeviceForAutomation +import me.proton.core.test.quark.Quark +import me.proton.core.test.quark.data.User +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Rule +import timber.log.Timber +import javax.inject.Inject + +/** + * @param logoutUsersOnTearDown by default, revoke the user's session against the API + * and logout the user after each test. + * If using orchestrator with `clearPackageData` option this might be redundant (might still be + * beneficial as it doesn't leave open sessions but doesn't impact the test itself) + */ +internal open class BaseTest( + private val logoutUsersOnTearDown: Boolean = true +) { + + @get:Rule(order = RuleOrder_00_First) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = RuleOrder_10_Initialization) + val mainInitializerRule = MainInitializerRule() + + @get:Rule(order = RuleOrder_11_Initialized) + val grantNotificationsPermissionRule = GrantNotificationsPermissionRule() + + @get:Rule(order = RuleOrder_20_Injection) + val hiltInjectRule = HiltInjectRule(hiltRule) + + @get:Rule(order = RuleOrder_30_ActivityLaunch) + val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.createAndGetComposeRule() + + @Inject + lateinit var loginTestHelper: LoginTestHelper + + @Inject + lateinit var mailSettingsRepo: MailSettingsRepository + + @Inject + lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule + + @Inject + lateinit var spotlightSeenRule: SpotlightSeenRule + + @Before + open fun setup() { + setupDeviceForAutomation(true) + loginTestHelper.logoutAll() + mockOnboardingRuntimeRule(shouldForceShow = false) + spotlightSeenRule.invoke(seen = true) + + ActivityScenario.launch(MainActivity::class.java) + } + + @After + open fun cleanup() { + if (logoutUsersOnTearDown) { + Timber.d("Finishing Testing: Revoking user sessions and logging out") + loginTestHelper.logoutAll() + } + } + + fun loginAndAwaitData(user: User) { + val sessionInfo = login(user) + + composeTestRule.waitUntil(5_000) { + runBlocking { mailSettingsRepo.getMailSettingsOrNull(sessionInfo.userId) != null } + } + } + + fun login(user: User): SessionInfo { + Timber.d("Login user: ${user.name}") + return loginTestHelper.login(user.name, user.password) + } + + companion object { + + const val RuleOrder_00_First = 0 + const val RuleOrder_10_Initialization = 10 + const val RuleOrder_11_Initialized = 11 + const val RuleOrder_20_Injection = 20 + const val RuleOrder_21_Injected = 21 + const val RuleOrder_30_ActivityLaunch = 30 + const val RuleOrder_31_ActivityLaunched = 31 + const val RuleOrder_99_Last = 99 + + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().context + + val users = User.Users.fromJson( + json = context.assets.open("users.json").bufferedReader().use { it.readText() } + ) + + private val envConfig: EnvironmentConfiguration = EnvironmentConfiguration.fromClass() + + val quark = Quark.fromJson( + json = context.assets.open("internal_api.json").bufferedReader().use { it.readText() }, + host = envConfig.host, + proxyToken = BuildConfig.PROXY_TOKEN + ) + + @JvmStatic + @BeforeClass + fun prepare() { + setupDeviceForAutomation(true) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt new file mode 100644 index 0000000000..d7b0b50069 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/HiltTestRunner.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +@Suppress("unused") // Used in Gradle config +internal class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context? + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt new file mode 100644 index 0000000000..a0d419ee7d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/MockedNetworkTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest + +import android.Manifest +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.rule.GrantPermissionRule +import ch.protonmail.android.test.idlingresources.ComposeIdlingResource +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.helpers.core.TestIdWatcher +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.login.LoginType +import ch.protonmail.android.uitest.helpers.network.authenticationDispatcher +import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule +import ch.protonmail.android.uitest.rule.MainInitializerRule +import ch.protonmail.android.uitest.rule.MockIntentsRule +import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule +import ch.protonmail.android.uitest.rule.MockTimeRule +import ch.protonmail.android.uitest.rule.SpotlightSeenRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.unmockkObject +import me.proton.core.network.domain.NetworkManager +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.rules.RuleChain +import javax.inject.Inject + +/** + * A base test class used in UI tests that require complete network isolation. + * + * @param captureIntents whether intents shall be captured (and mocked) for further verification. + * @param loginType the login type to use for a given test suite. + */ +@HiltAndroidTest +internal open class MockedNetworkTest( + captureIntents: Boolean = true, + private val showOnboarding: Boolean = false, + private val loginType: LoginType = LoginTestUserTypes.Deprecated.GrumpyCat +) { + + private val hiltAndroidRule = HiltAndroidRule(this) + private val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.createAndGetComposeRule() + private val writeExtStoragePermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + + @Inject + lateinit var mockWebServer: MockWebServer + + @Inject + lateinit var idlingResources: Set<@JvmSuppressWildcards ComposeIdlingResource> + + @Inject + lateinit var networkManager: NetworkManager + + @Inject + lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule + + @Inject + lateinit var spotlightSeenRule: SpotlightSeenRule + + @get:Rule + val ruleChain: RuleChain = RuleChain.outerRule( + hiltAndroidRule + ).around( + composeTestRule + ).around( + writeExtStoragePermissionRule + ).around( + GrantNotificationsPermissionRule() + ).around( + MockIntentsRule(captureIntents) + ).around( + MainInitializerRule() + ).around( + MockTimeRule() + ).around( + TestIdWatcher() + ) + + @Before + fun setup() { + hiltAndroidRule.inject() + + idlingResources.forEach { idlingResource -> + idlingResource.clear() + composeTestRule.registerIdlingResource(idlingResource) + } + + mockWebServer.dispatcher = authenticationDispatcher(loginType) + mockOnboardingRuntimeRule(showOnboarding) + spotlightSeenRule.invoke(seen = true) + } + + @After + fun tearDown() { + idlingResources.forEach { composeTestRule.unregisterIdlingResource(it) } + unmockkObject(networkManager) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt new file mode 100644 index 0000000000..aa6c3ae093 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/CoreBaseNetworkTestModule.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.di + +import java.security.SecureRandom +import java.security.cert.X509Certificate +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.spyk +import me.proton.core.network.dagger.CoreBaseNetworkModule +import me.proton.core.network.data.NetworkManager +import me.proton.core.network.data.di.SharedOkHttpClient +import me.proton.core.network.domain.NetworkManager +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockWebServer +import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + +/** + * A test module that overrides the [CoreBaseNetworkModule] to allow HTTPS connections between the app and + * the local [MockWebServer] with a customized [OkHttpClient]. + * + * The provided [NetworkManager] is the same as the one currently used in production code. + */ +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [CoreBaseNetworkModule::class]) +class CoreBaseNetworkTestModule { + + private val testX509TrustManager = object : X509TrustManager { + override fun checkClientTrusted(p0: Array?, p1: String?) = Unit + override fun checkServerTrusted(p0: Array?, p1: String?) = Unit + override fun getAcceptedIssuers(): Array = arrayOf() + } + + @Provides + @Reusable + @TestClientSSLSocketFactory + internal fun provideTestClientSSLSocketFactory(): SSLSocketFactory { + return SSLContext.getInstance("TLS").apply { + init(null, arrayOf(testX509TrustManager), SecureRandom()) + }.socketFactory + } + + @Provides + @Singleton + @SharedOkHttpClient + internal fun provideOkHttpClient(@TestClientSSLSocketFactory sslSocketFactory: SSLSocketFactory): OkHttpClient = + OkHttpClient.Builder().sslSocketFactory(sslSocketFactory, testX509TrustManager).build() + + @Provides + @Singleton + internal fun provideNetworkManager(@ApplicationContext context: Context): NetworkManager = + spyk(NetworkManager(context)) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt new file mode 100644 index 0000000000..dbf3e2928d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/LocalhostApiModule.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Qualifier + +/** + * Convenient annotation to ease the injection of the [Boolean] flag indicating whether we want to force + * the use of localhost when resolving the base URL for UI Tests. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LocalhostApi + +/** + * This is a custom test module that determines whether the API calls shall be mocked in UI Tests. + * + * Since we can't use [TestInstallIn] multiple times in our tests and we don't want to bloat the test suites with + * multiple [InstallIn], [LocalhostApi] is introduced to set whether tests should run in complete network isolation. + */ +@Module +@InstallIn(SingletonComponent::class) +object LocalhostApiModule { + + @Provides + @LocalhostApi + fun useLocalhostApi(): Boolean = true +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt new file mode 100644 index 0000000000..31c5641301 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/NetworkConfigTestModule.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.di + +import ch.protonmail.android.di.NetworkConfigModule +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import me.proton.core.configuration.EnvironmentConfiguration +import me.proton.core.network.data.di.BaseProtonApiUrl +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.mockwebserver.MockWebServer + +/** + * A test module used to override the [BaseProtonApiUrl] in UI Tests. + */ +@Module +@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkConfigModule::class]) +object NetworkConfigTestModule { + + @Provides + @BaseProtonApiUrl + fun provideBaseProtonApiUrl( + @LocalhostApi localhostApi: Boolean, + mockWebServer: MockWebServer, + envConfig: EnvironmentConfiguration + ): HttpUrl { + return if (localhostApi) { + runBlocking { + withContext(Dispatchers.IO) { + mockWebServer.url("/") + } + } + } else { + // This is a temporary solution until we come up with an efficient environment switch. + envConfig.baseUrl.toHttpUrl() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt new file mode 100644 index 0000000000..6b5e955f23 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/di/TestClientSSLSocketFactory.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.di + +import okhttp3.mockwebserver.MockWebServer +import javax.inject.Qualifier +import javax.net.ssl.SSLSocketFactory + +/** + * Annotation used to inject a client-specific [SSLSocketFactory] instance in UI Tests to enable HTTPS + * communication between the app and the local [MockWebServer]. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TestClientSSLSocketFactory diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt new file mode 100644 index 0000000000..66bcfce5b7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/AddAccountRobotProxy.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.account + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import ch.protonmail.android.R +import me.proton.core.test.android.robots.auth.AddAccountRobot + +fun addAccountRobot(block: AddAccountRobot.() -> Unit) = AddAccountRobot().apply(block) + +fun AddAccountRobot.Verify.isDisplayed() { + // There are no efficient ways to check for this since it does not have a root view id. + onView(withId(R.id.sign_in)).check(matches(ViewMatchers.isDisplayed())) + onView(withId(R.id.sign_up)).check(matches(ViewMatchers.isDisplayed())) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt new file mode 100644 index 0000000000..d300b7de0b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/account/SignOutAccountTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.account + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.account.section.buttonsSection +import ch.protonmail.android.uitest.robot.account.signOutAccountDialogRobot +import ch.protonmail.android.uitest.robot.account.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class SignOutAccountTest : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun navigateToLogout() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher() + navigator { navigateTo(Destination.SidebarMenu) } + menuRobot { tapSignOut() } + } + + @Test + @TestId("256595") + fun testSignOutIsPerformedOnDialogConfirmationWhenSingleAccountLoggedIn() { + signOutAccountDialogRobot { + buttonsSection { tapSignOut() } + // Do not call isNotShown() here as it transitions to an external non-compose screen. + } + + addAccountRobot { + verify { isDisplayed() } + } + } + + @Test + @TestId("256596") + fun testSignOutIsNotPerformedOnDialogCancellationWhenSingleAccountLoggedIn() { + signOutAccountDialogRobot { + buttonsSection { tapCancel() } + verify { isNotShown() } + } + + mailboxRobot { + verify { isShown() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt new file mode 100644 index 0000000000..873b6f12e6 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/accountrecovery/AccountRecoveryFlowTest.kt @@ -0,0 +1,93 @@ +package ch.protonmail.android.uitest.e2e.accountrecovery + +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import ch.protonmail.android.uitest.BaseTest +import ch.protonmail.android.uitest.di.LocalhostApi +import ch.protonmail.android.uitest.di.LocalhostApiModule +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import me.proton.core.accountmanager.data.AccountStateHandler +import me.proton.core.accountrecovery.dagger.CoreAccountRecoveryFeaturesModule +import me.proton.core.accountrecovery.domain.IsAccountRecoveryEnabled +import me.proton.core.accountrecovery.domain.IsAccountRecoveryResetEnabled +import me.proton.core.accountrecovery.test.MinimalAccountRecoveryNotificationTest +import me.proton.core.auth.test.flow.SignInFlow +import me.proton.core.auth.test.robot.AddAccountRobot +import me.proton.core.auth.test.usecase.WaitForPrimaryAccount +import me.proton.core.domain.entity.UserId +import me.proton.core.eventmanager.domain.EventManagerProvider +import me.proton.core.eventmanager.domain.repository.EventMetadataRepository +import me.proton.core.network.data.ApiProvider +import me.proton.core.notification.dagger.CoreNotificationFeaturesModule +import me.proton.core.notification.domain.repository.NotificationRepository +import me.proton.core.notification.domain.usecase.IsNotificationsEnabled +import me.proton.core.test.android.instrumented.FusionConfig +import javax.inject.Inject + +@CoreLibraryTest +@HiltAndroidTest +@UninstallModules( + LocalhostApiModule::class, + CoreAccountRecoveryFeaturesModule::class, + CoreNotificationFeaturesModule::class +) +internal class AccountRecoveryFlowTest : BaseTest(), MinimalAccountRecoveryNotificationTest { + @JvmField + @BindValue + @LocalhostApi + val localhostApi = false + + @Inject + override lateinit var accountStateHandler: AccountStateHandler + + @Inject + override lateinit var apiProvider: ApiProvider + + @Inject + override lateinit var eventManagerProvider: EventManagerProvider + + @Inject + override lateinit var eventMetadataRepository: EventMetadataRepository + + @Inject + override lateinit var notificationRepository: NotificationRepository + + @Inject + override lateinit var waitForPrimaryAccount: WaitForPrimaryAccount + + @BindValue + internal val isAccountRecoveryEnabled = object : IsAccountRecoveryEnabled { + override fun invoke(userId: UserId?): Boolean = true + override fun isLocalEnabled(): Boolean = true + override fun isRemoteEnabled(userId: UserId?): Boolean = true + } + + @BindValue + internal val isAccountRecoveryResetEnabled = object : IsAccountRecoveryResetEnabled { + override fun invoke(userId: UserId?): Boolean = true + override fun isLocalEnabled(): Boolean = true + override fun isRemoteEnabled(userId: UserId?): Boolean = true + } + + @BindValue + internal val isNotificationsEnabled = IsNotificationsEnabled { true } + + init { + FusionConfig.Compose.testRule = composeTestRule + } + + override fun setup() { + super.setup() + val user = users.getUser { it.name == "pro" } + + AddAccountRobot.clickSignIn() + SignInFlow.signInInternal(user.name, user.password) + } + + override fun verifyAfterLogin() { + mailboxRobot { verify { isShown() } } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt new file mode 100644 index 0000000000..ce6921b1a5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerMainTests.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.keyboardSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import ch.protonmail.android.uitest.robot.composer.section.senderSection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import ch.protonmail.android.uitest.robot.composer.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerMainTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun setMockDispatcher() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + } + + @Test + @TestId("79036") + fun checkComposerMainFieldsAndInteractions() { + val expectedSender = "fancycapybara@proton.black" + val expectedRecipient = "test@example.com" + val expectedSubject = "Subject" + val typedBody = "Text message" + val expectedBody = "$typedBody\n\nSent from Proton Mail Android" + + navigator { + navigateTo(Destination.Composer) + } + + composerRobot { + verify { composerIsShown() } + + // Sender field + senderSection { + verify { hasValue(expectedSender) } + } + + keyboardSection { + verify { keyboardIsShown() } + } + + // Recipient field + toRecipientSection { + verify { + isFieldFocused() + isEmptyField() + } + + typeRecipient(expectedRecipient) + verify { hasRecipient(expectedRecipient) } + } + + // Subject field + subjectSection { + verify { hasEmptySubject() } + typeSubject(expectedSubject) + verify { hasSubject(expectedSubject) } + } + + // Message body field + messageBodySection { + typeMessageBody(typedBody) + verify { hasText(expectedBody) } + } + } + } + + @Test + @TestId("79037") + fun checkComposerCloseNavigation() { + navigator { + navigateTo(Destination.Composer) + } + + composerRobot { + topAppBarSection { tapCloseButton() } + } + + mailboxRobot { + verify { isShown() } + } + } + + @Test + @TestId("79038") + fun checkComposerBackButtonNavigation() { + navigator { + navigateTo(Destination.Composer) + } + + composerRobot { + keyboardSection { dismissKeyboard() } + } + + uiDevice.pressBack() + + mailboxRobot { + verify { isShown() } + } + } + + @Test + @TestId("79039") + fun checkComposerKeyboardDismissalWithBackButton() { + navigator { + navigateTo(Destination.Composer) + } + + composerRobot { + keyboardSection { + dismissKeyboard() + + verify { keyboardIsNotShown() } + } + } + } + + @Test + @TestId("190226", "190227") + fun testCollapseExpandChevron() { + navigator { + navigateTo(Destination.Composer) + } + + composerRobot { + toRecipientSection { + expandCcAndBccFields() + verify { isEmptyField() } + } + + ccRecipientSection { + verify { isEmptyField() } + } + + bccRecipientSection { + verify { isEmptyField() } + } + + toRecipientSection { + hideCcAndBccFields() + } + + ccRecipientSection { + verify { isHidden() } + } + + bccRecipientSection { + verify { isHidden() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt new file mode 100644 index 0000000000..1311e06600 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/ComposerTests.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer + +import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.put +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.helpers.mockRobot +import ch.protonmail.android.uitest.robot.helpers.section.time + +internal interface ComposerTests { + + fun composerMockNetworkDispatcher( + useDefaultMailSettings: Boolean = true, + useDefaultMessagesList: Boolean = true, + useDefaultContacts: Boolean = true, + useDefaultDraftUploadResponse: Boolean = false, + useDefaultSendMessageResponse: Boolean = false, + useDefaultRecipientKeys: Boolean = true, + mockDefinitions: MockNetworkDispatcher.() -> Unit = {} + ) = mockNetworkDispatcher( + useDefaultMailSettings = useDefaultMailSettings, + useDefaultContacts = useDefaultContacts + ) { + + if (useDefaultMessagesList) { + addMockRequests( + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + if (useDefaultDraftUploadResponse) { + addMockRequests( + post("/mail/v4/messages") + respondWith "/mail/v4/messages/post/post_messages_base_create_placeholder.json" + withStatusCode 200 serveOnce true, + put("/mail/v4/messages/*") + respondWith "/mail/v4/messages/put/put_messages_base_placeholder.json" + withStatusCode 200 matchWildcards true, + ) + } + + if (useDefaultSendMessageResponse) { + addMockRequests( + post("/mail/v4/messages/*") + respondWith "/mail/v4/messages/post/post_messages_base_send_placeholder.json" + withStatusCode 200 matchWildcards true serveOnce true withNetworkDelay 500L + ) + } + + if (useDefaultRecipientKeys) { + addMockRequests( + get("/core/v4/keys?Email=royalcat%40proton.black") + respondWith "/core/v4/keys/keys_royalcat.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=royaldog%40proton.black") + respondWith "/core/v4/keys/keys_royaldog.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=specialfox%40proton.black") + respondWith "/core/v4/keys/keys_specialfox.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=sleepykoala%40proton.black") + respondWith "/core/v4/keys/keys_sleepykoala.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=strangewalrus%40proton.black") + respondWith "/core/v4/keys/keys_strangewalrus.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=happyllama%40proton.black") + respondWith "/core/v4/keys/keys_happyllama.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test%40example.com") + respondWith "/core/v4/keys/keys_testexample.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test2%40example.com") + respondWith "/core/v4/keys/keys_test2example.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test3%40example.com") + respondWith "/core/v4/keys/keys_test3example.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test4%40example.com") + respondWith "/core/v4/keys/keys_test4example.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test5%40example.com") + respondWith "/core/v4/keys/keys_test5example.json" + withStatusCode 200 serveOnce true, + get("/core/v4/keys?Email=test6%40example.com") + respondWith "/core/v4/keys/keys_test6example.json" + withStatusCode 200 serveOnce true + ) + } + + mockDefinitions() + } + + fun ComposerRobot.prepareDraft( + toRecipient: String? = null, + ccRecipient: String? = null, + bccRecipient: String? = null, + subject: String? = null, + body: String? = null + ) { + prepareDraft( + toRecipient?.let { listOf(it) } ?: emptyList(), + ccRecipient?.let { listOf(it) } ?: emptyList(), + bccRecipient?.let { listOf(it) } ?: emptyList(), + subject, + body + ) + } + + fun ComposerRobot.prepareDraft( + toRecipients: List = emptyList(), + ccRecipients: List = emptyList(), + bccRecipients: List = emptyList(), + subject: String? = null, + body: String? = null + ) { + mockRobot { + time { forceCurrentMillisTo(1_688_211_755) } // Jul 1st, 2023 + } + + composerRobot { + toRecipientSection { + toRecipients.forEach { typeRecipient(it, autoConfirm = true) } + } + + if (ccRecipients.isNotEmpty() || bccRecipients.isNotEmpty()) { + toRecipientSection { expandCcAndBccFields() } + } + + ccRecipientSection { + ccRecipients.forEach { typeRecipient(it, autoConfirm = true) } + } + + bccRecipientSection { + bccRecipients.forEach { typeRecipient(it, autoConfirm = true) } + } + + subject?.let { + subjectSection { typeSubject(it) } + } + + body?.let { + messageBodySection { typeMessageBody(body) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt new file mode 100644 index 0000000000..1de99a01ac --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsButtonTests.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.attachments + +import java.time.Instant +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@SdkSuppress(minSdkVersion = 30, maxSdkVersion = 32) +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerAttachmentsButtonTests : MockedNetworkTest(), ComposerAttachmentsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private lateinit var attachmentName: String + private val defaultExpectedEntry: AttachmentDetailItemEntry + get() = AttachmentDetailItemEntry( + index = 0, + fileName = attachmentName, + fileSize = "78 kB", + hasDeleteIcon = true + ) + + @Before + fun setupTests() { + attachmentName = "${Instant.now().epochSecond}.jpg" + val uri = initFakeFileUri("placeholder_image.jpg", attachmentName, "image/jpg") + stubPickerActivityResultWithUri(uri) + + mockWebServer.dispatcher combineWith mockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("226087", "226088") + fun testMainAttachmentsButtonInteractions() { + composerRobot { + topAppBarSection { tapAttachmentsButton() } + } + + deviceRobot { + intents { verify { filePickerIntentWasLaunched() } } + } + } + + @Test + @SdkSuppress(minSdkVersion = 29) + @TestId("226090") + fun testAttachmentChipEntryUponPicking() { + composerRobot { + topAppBarSection { tapAttachmentsButton() } + attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } } + } + } + + @Test + @SdkSuppress(minSdkVersion = 29) + @TestId("226091") + fun testAttachmentChipDuplicateEntryUponPicking() { + val expectedEntries = arrayOf( + defaultExpectedEntry, + defaultExpectedEntry.copy(index = 1) + ) + + composerRobot { + topAppBarSection { tapAttachmentsButton() } + attachmentsSection { verify { hasAttachments(defaultExpectedEntry) } } + + topAppBarSection { tapAttachmentsButton() } + attachmentsSection { verify { hasAttachments(*expectedEntries) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt new file mode 100644 index 0000000000..701621f58a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerAttachmentsTests.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.attachments + +import android.app.Activity +import android.app.Instrumentation +import android.content.ContentValues +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import ch.protonmail.android.networkmocks.assets.RawAssets +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.util.InstrumentationHolder + +internal interface ComposerAttachmentsTests : ComposerTests { + + /** + * Pushes a file from internal assets into the Download directory of the targeted device + * and returns the newly created file's URI. + * + * @param testAssetFileName the file name provided by internal assets. + * @param downloadDirFileName the name of the locally copied file. + * @param mimeType the MIME type of the file. + * + * @return the created file URI. + */ + fun initFakeFileUri(testAssetFileName: String, downloadDirFileName: String, mimeType: String): Uri { + val contentResolver = InstrumentationHolder.instrumentation.targetContext.contentResolver + + val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, downloadDirFileName) + put(MediaStore.Downloads.MIME_TYPE, mimeType) + }) + + requireNotNull(uri) { "Generated file Uri is null" } // `null` should never happen + + val assetPath = "$RawAssetsRootDir/$testAssetFileName" + val outputStream = requireNotNull(contentResolver.openOutputStream(uri, "w")) { "Unable to open output stream" } + val fileInputStream = + requireNotNull(RawAssets.getInputStreamForPath(assetPath)) { "Unable to open input stream for path $assetPath" } + + fileInputStream.use { outputStream.write(it.readBytes()) } + + return uri + } + + /** + * Stubs the File Picker (or similar intent actions) Activity Result by passing the given [Uri] as the [Intent] data. + * + * @param uri the file URI that needs to be returned + */ + fun stubPickerActivityResultWithUri(uri: Uri) { + val intent = Intent().apply { data = uri } + val result = Instrumentation.ActivityResult(Activity.RESULT_OK, intent) + Intents.intending(IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT)).respondWith(result) + } + + private companion object { + + val RawAssetsRootDir = "assets/raw" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt new file mode 100644 index 0000000000..a111ebec0f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/attachments/ComposerSendMessageWithAttachmentsTests.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.attachments + +import java.time.Instant +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.simulateNoNetwork +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.every +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import kotlin.test.Test + +@RegressionTest +@HiltAndroidTest +@SdkSuppress(minSdkVersion = 30, maxSdkVersion = 32) +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageWithAttachmentsTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + ComposerAttachmentsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val protonRecipient = "sleepykoala@proton.black" + private val subject = "Test subject" + private val body = "Test body" + + @Before + fun setupTests() { + val attachmentName = Instant.now().epochSecond.toString() + val uri = initFakeFileUri("placeholder_image.jpg", attachmentName, "image/jpg") + stubPickerActivityResultWithUri(uri) + } + + @Test + @SmokeTest + @TestId("226734") + fun testAttachmentSendingToProtonMailAddress() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) { + addMockRequests( + post("/mail/v4/attachments") + respondWith "/mail/v4/attachments/attachments_226734.json" + withStatusCode 200 + ) + } + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prefillMessageWithAttachment() + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } } + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSent) } } + } + } + + @Test + @TestId("226735") + fun testAttachmentSendingToProtonMailAddressWithError() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + post("/mail/v4/attachments") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 + ) + } + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prefillMessageWithAttachment() + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } } + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @TestId("226736") + fun testAttachmentSendingToProtonMailAddressWhenOfflineError() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + post("/mail/v4/attachments") + simulateNoNetwork true + ) + } + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prefillMessageWithAttachment() + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } } + + // Deferred as on FTL it might propagate too quickly. + every { networkManager.isConnectedToNetwork() } returns false + + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + private fun ComposerRobot.prefillMessageWithAttachment() { + toRecipientSection { typeRecipient(protonRecipient, autoConfirm = true) } + subjectSection { typeSubject(subject) } + messageBodySection { typeMessageBody(body) } + + topAppBarSection { tapAttachmentsButton() } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt new file mode 100644 index 0000000000..3ae8087d58 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerChipsTests.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.ComposerRecipientsSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify + +internal interface ComposerChipsTests : ComposerTests { + + fun ComposerRecipientsSection.createAndVerifyChip( + state: RecipientChipValidationState, + trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction + ) { + val text = when (state) { + is RecipientChipValidationState.Valid -> "rec@ipient.com" + is RecipientChipValidationState.Invalid -> "test" + } + + val chip = RecipientChipEntry( + index = 0, text = text, state = state + ) + + typeRecipient(chip.text) + triggerChipCreation(trigger) + + verify { + hasRecipientChips( + chip.copy(hasDeleteIcon = true) + ) + } + } + + fun withMultipleRecipients( + size: Int, + state: RecipientChipValidationState, + block: (RecipientChipEntry) -> Any + ) { + (0 until size).forEach { index -> + val recipient = StringBuilder("test$index").apply { + if (state == RecipientChipValidationState.Valid) append("@email.com") + }.toString() + + val recipientChipEntry = RecipientChipEntry( + index = index, + text = recipient, + hasDeleteIcon = true, + state = state + ) + + block(recipientChipEntry) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt new file mode 100644 index 0000000000..7d5ce95e65 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsChipsDeletionTests.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.ComposerRecipientsSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerRecipientsChipsDeletionTests : MockedNetworkTest(), ComposerChipsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("190261") + fun testValidChipDeletion() { + val expectedValidChip = RecipientChipEntry( + index = 0, + text = "delete@me.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + + composerRobot { + toRecipientSection { + validateSimpleChipCreationAndDeletion(expectedValidChip) + } + } + } + + @Test + @SmokeTest + @TestId("190262") + fun testInvalidChipDeletion() { + val expectedInvalidChip = RecipientChipEntry( + index = 0, + text = "deleteme.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Invalid + ) + + composerRobot { + toRecipientSection { + validateSimpleChipCreationAndDeletion(expectedInvalidChip) + } + } + } + + @Test + @TestId("190263") + fun testMultipleChipsLastChipDeletion() { + val expectedFinalChips = arrayOf( + RecipientChipEntry( + index = 0, + text = "one", + hasDeleteIcon = true, + state = RecipientChipValidationState.Invalid + ), + RecipientChipEntry( + index = 1, + text = "two@test.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + ) + + val expectedChip = RecipientChipEntry( + index = 2, + text = "delete@me.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + + composerRobot { + toRecipientSection { + typeMultipleRecipients(expectedFinalChips[0].text, expectedFinalChips[1].text) + validateSimpleChipCreationAndDeletion(expectedChip) + verify { hasRecipientChips(*expectedFinalChips) } + } + } + } + + @Test + @TestId("190264") + fun testMultipleChipsFirstChipDeletion() { + val toBeDeletedChipText = "additional@chip.com" + val expectedFinalChips = arrayOf( + RecipientChipEntry( + index = 0, + text = "one", + hasDeleteIcon = true, + state = RecipientChipValidationState.Invalid + ), + RecipientChipEntry( + index = 1, + text = "two@test.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + ) + + composerRobot { + toRecipientSection { + typeMultipleRecipients(toBeDeletedChipText, expectedFinalChips[0].text, expectedFinalChips[1].text) + deleteChipAt(position = 0) + verify { hasRecipientChips(*expectedFinalChips) } + } + } + } + + @Test + @TestId("190265") + fun testMultipleChipsMiddleChipDeletion() { + val toBeDeletedChipText = "additional@chip.com" + val expectedFinalChips = arrayOf( + RecipientChipEntry( + index = 0, + text = "one", + hasDeleteIcon = true, + state = RecipientChipValidationState.Invalid + ), + RecipientChipEntry( + index = 1, + text = "two@test.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + ) + + composerRobot { + toRecipientSection { + typeMultipleRecipients(expectedFinalChips[0].text, toBeDeletedChipText, expectedFinalChips[1].text) + deleteChipAt(position = 1) + + verify { hasRecipientChips(*expectedFinalChips) } + } + } + } + + @Test + @TestId("190266") + fun testAllChipsDeletion() { + val rawRecipients = arrayOf("test1@example.com", "test2@example.com", "test3@example.com") + + composerRobot { + toRecipientSection { + typeMultipleRecipients(*rawRecipients) + + for (index in 1..rawRecipients.size) { + deleteChipAt(rawRecipients.size - index) + } + + verify { isEmptyField() } + } + } + } + + @Test + @TestId("190269") + fun testValidChipDeletionWithBackspace() { + composerRobot { + toRecipientSection { + typeRecipient("rec@ipient.com") + triggerChipCreation(ChipsCreationTrigger.NewLine) + tapBackspace() + + verify { isEmptyField() } + } + } + } + + @Test + @TestId("190270") + fun testInvalidChipDeletionWithBackspace() { + composerRobot { + toRecipientSection { + typeRecipient("example.com") + triggerChipCreation(ChipsCreationTrigger.NewLine) + tapBackspace() + + verify { isEmptyField() } + } + } + } + + @Test + @TestId("190271") + fun testMultipleValidChipsDeletionWithBackspace() { + val rawRecipients = arrayOf("rec@ipient.com", "rec@ipient1.com", "rec@ipient2.com") + + composerRobot { + toRecipientSection { + validateChipsCreationAndDeletionWithBackspace(rawRecipients) + } + } + } + + @Test + @TestId("190272") + fun testMultipleInvalidChipsDeletionWithBackspace() { + val rawRecipients = arrayOf("recipient.com", "recipient1.com", "recipient2.com") + + composerRobot { + toRecipientSection { + validateChipsCreationAndDeletionWithBackspace(rawRecipients) + } + } + } + + @Test + @TestId("190273") + fun testChipDeletionAndRecreation() { + composerRobot { + toRecipientSection { + validateChipDeletionAndRecreation { deleteChipAt(position = 1) } + } + } + } + + @Test + @TestId("190274") + fun testChipDeletionAndRecreationWithBackspace() { + composerRobot { + toRecipientSection { + validateChipDeletionAndRecreation { tapBackspace() } + } + } + } + + private fun ComposerRecipientsSection.validateSimpleChipCreationAndDeletion(chipEntry: RecipientChipEntry) { + typeRecipient(chipEntry.text) + triggerChipCreation(ChipsCreationTrigger.NewLine) + verify { hasRecipientChips(chipEntry) } + + deleteChipAt(chipEntry.index) + verify { + recipientChipIsNotDisplayed(chipEntry) + isFieldFocused() + } + } + + private fun ComposerRecipientsSection.validateChipsCreationAndDeletionWithBackspace(rawRecipients: Array) { + typeMultipleRecipients(*rawRecipients) + repeat(times = rawRecipients.size) { tapBackspace() } + + verify { + isFieldFocused() + isEmptyField() + } + } + + private fun ComposerRecipientsSection.validateChipDeletionAndRecreation( + deleteAction: ComposerRecipientsSection.() -> Unit + ) { + val expectedChips = arrayOf( + RecipientChipEntry( + index = 0, + text = "rec@ipient.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ), + RecipientChipEntry( + index = 1, + text = "rec@ipient2.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + ) + + typeMultipleRecipients("rec@ipient.com", "rec@ipient0.com") + deleteAction() + typeRecipient("rec@ipient2.com", autoConfirm = true) + + verify { hasRecipientChips(*expectedChips) } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt new file mode 100644 index 0000000000..21d301fb66 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsCollapsedChipsTests.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerRecipientsCollapsedChipsTests : MockedNetworkTest(), ComposerChipsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedPlusOneEntry = RecipientChipEntry( + index = 1, + text = "+1", + hasDeleteIcon = false, + state = RecipientChipValidationState.Valid + ) + + private val expectedMultipleFocusedEntries = arrayOf( + RecipientChipEntry( + index = 0, + text = "test@example.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ), + RecipientChipEntry( + index = 1, + text = "test2@example.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ), + RecipientChipEntry( + index = 2, + text = "test3@example.com", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + ) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("190243") + fun testMoveFocusFromToFieldCollapseChips() { + composerRobot { + toRecipientSection { + typeMultipleRecipients("recipient1@example.com", "recipient2@example.com") + } + + subjectSection { focusField() } + + toRecipientSection { + verify { hasRecipientChips(expectedPlusOneEntry) } + } + } + } + + @Test + @TestId("190244") + fun testMoveFocusFromCcFieldCollapseChips() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + ccRecipientSection { + typeMultipleRecipients("recipient1@example.com", "recipient2@example.com") + } + + subjectSection { focusField() } + + ccRecipientSection { + verify { hasRecipientChips(expectedPlusOneEntry) } + } + } + } + + @Test + @TestId("190245") + fun testMoveFocusFromBccFieldCollapseChips() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + bccRecipientSection { + typeMultipleRecipients("recipient1@example.com", "recipient2@example.com") + } + + subjectSection { focusField() } + + bccRecipientSection { + verify { hasRecipientChips(expectedPlusOneEntry) } + } + } + } + + @Test + @SmokeTest + @TestId("190246") + fun testExpandedChipsRestoreRecipientsContents() { + val unfocusedEntries = arrayOf( + RecipientChipEntry( + index = 0, + text = "test@example.com", + hasDeleteIcon = false, + state = RecipientChipValidationState.Valid + ), + RecipientChipEntry( + index = 1, + text = "+2", + hasDeleteIcon = false, + state = RecipientChipValidationState.Valid + ) + ) + + composerRobot { + toRecipientSection { + typeMultipleRecipients("test@example.com", "test2@example.com", "test3@example.com") + + verify { hasRecipientChips(*expectedMultipleFocusedEntries) } + } + + subjectSection { focusField() } + + toRecipientSection { + verify { hasRecipientChips(*unfocusedEntries) } + + focusField() + + verify { hasRecipientChips(*expectedMultipleFocusedEntries) } + } + } + } + + @Test + @TestId("190247") + fun testChipAdditionAfterFieldExpansion() { + composerRobot { + toRecipientSection { + typeMultipleRecipients("test@example.com", "test2@example.com") + } + + subjectSection { focusField() } + + toRecipientSection { + typeRecipient("test3@example.com", autoConfirm = true) + + verify { hasRecipientChips(*expectedMultipleFocusedEntries) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt new file mode 100644 index 0000000000..2d8e5ffe10 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsDuplicatedChipsTests.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerRecipientsDuplicatedChipsTests : MockedNetworkTest(), ComposerChipsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedRecipientChip = RecipientChipEntry( + index = 0, + text = "rec@ipient.com", + state = RecipientChipValidationState.Valid + ) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("190255") + fun testSameAddressInMultipleField() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + typeRecipient(expectedRecipientChip.text) + } + + ccRecipientSection { + typeRecipient(expectedRecipientChip.text) + } + + bccRecipientSection { + typeRecipient(expectedRecipientChip.text) + } + + subjectSection { focusField() } + + toRecipientSection { + verify { hasRecipientChips(expectedRecipientChip) } + } + + ccRecipientSection { + verify { hasRecipientChips(expectedRecipientChip) } + } + + bccRecipientSection { + verify { hasRecipientChips(expectedRecipientChip) } + } + } + } + + @Test + @TestId("190256") + fun testSameAddressInSameFieldIsDiscarded() { + val expectedFocusedRecipientChip = expectedRecipientChip.copy(hasDeleteIcon = true) + val expectedNotExistsChip = expectedFocusedRecipientChip.copy(index = 1) + + composerRobot { + toRecipientSection { + typeMultipleRecipients(expectedFocusedRecipientChip.text, expectedFocusedRecipientChip.text) + + verify { + hasRecipientChips(expectedFocusedRecipientChip) + recipientChipIsNotDisplayed(expectedNotExistsChip) + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt new file mode 100644 index 0000000000..72309502dd --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsInvalidChipsTests.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.ComposerRecipientsSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerRecipientsInvalidChipsTests : MockedNetworkTest(), ComposerChipsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedRecipientChip = RecipientChipEntry( + index = 0, + text = "test", + state = RecipientChipValidationState.Invalid + ) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("190232") + fun testInvalidToRecipientChip() { + composerRobot { + toRecipientSection { + createAndVerifyInvalidChip() + } + } + } + + @Test + @TestId("190233") + fun testInvalidCcRecipientChip() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + ccRecipientSection { + createAndVerifyInvalidChip() + } + } + } + + @Test + @TestId("190234") + fun testInvalidBccRecipientChip() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + bccRecipientSection { + createAndVerifyInvalidChip() + } + } + } + + @Test + @TestId("190235") + fun testInvalidRecipientChipOnFocusChange() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + typeRecipient("test") + } + + ccRecipientSection { + tapRecipientField() + } + + toRecipientSection { + verify { hasRecipientChips(expectedRecipientChip) } + } + } + } + + @Test + @TestId("190238") + fun testInvalidRecipientChipOnOnNewLine() { + composerRobot { + toRecipientSection { + createAndVerifyInvalidChip(trigger = ChipsCreationTrigger.NewLine) + } + } + } + + @Test + @TestId("190239") + fun testMultipleInvalidRecipientChipsOnNewLine() { + composerRobot { + toRecipientSection { + withMultipleRecipients(size = 100, state = RecipientChipValidationState.Invalid) { + typeRecipient(it.text, autoConfirm = true) + + verify { hasRecipientChips(it) } + } + } + } + } + + private fun ComposerRecipientsSection.createAndVerifyInvalidChip( + trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction + ) = createAndVerifyChip(state = RecipientChipValidationState.Invalid, trigger) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt new file mode 100644 index 0000000000..c3cec91cea --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/chips/ComposerRecipientsValidChipsTests.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.chips + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.recipients.ComposerRecipientsSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerRecipientsValidChipsTests : MockedNetworkTest(), ComposerChipsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedRecipientChipEntry = RecipientChipEntry( + index = 0, + text = "rec@ipient.com", + state = RecipientChipValidationState.Valid + ) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("190228") + fun testValidToRecipientChip() { + composerRobot { + toRecipientSection { + createAndVerifyValidChip() + } + } + } + + @Test + @TestId("190229") + fun testValidCcRecipientChip() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + ccRecipientSection { + createAndVerifyValidChip() + } + } + } + + @Test + @TestId("190230") + fun testValidBccRecipientChip() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + } + + bccRecipientSection { + createAndVerifyValidChip() + } + } + } + + @Test + @TestId("190231") + fun testValidRecipientChipOnFocusChange() { + composerRobot { + toRecipientSection { + expandCcAndBccFields() + typeRecipient("rec@ipient.com") + } + + ccRecipientSection { + tapRecipientField() + } + + toRecipientSection { + verify { hasRecipientChips(expectedRecipientChipEntry) } + } + } + } + + @Test + @TestId("190236") + fun testValidRecipientChipOnNewLine() { + composerRobot { + toRecipientSection { + createAndVerifyValidChip(trigger = ChipsCreationTrigger.NewLine) + } + } + } + + @Test + @TestId("190237") + fun testMultipleValidRecipientChipsOnNewLine() { + composerRobot { + toRecipientSection { + withMultipleRecipients(size = 100, state = RecipientChipValidationState.Valid) { + typeRecipient(it.text, autoConfirm = true) + + verify { hasRecipientChips(it) } + } + } + } + } + + private fun ComposerRecipientsSection.createAndVerifyValidChip( + trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction + ) = createAndVerifyChip(state = RecipientChipValidationState.Valid, trigger) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt new file mode 100644 index 0000000000..9886d6ad8e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsInvalidRecipientsTests.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerDraftsInvalidRecipientsTests : MockedNetworkTest(), ComposerDraftsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val invalidEmailAddress = "test@aa" + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("207358") + fun testIncompleteAddressDoesNotTriggerDraftCreation() { + composerRobot { + toRecipientSection { typeRecipient(invalidEmailAddress) } + + topAppBarSection { tapCloseButton() } + } + + verifyEmptyDrafts() + } + + @Test + @TestId("207359") + fun testInvalidToAddressDoesNotTriggerDraftCreation() { + composerRobot { + toRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) } + + topAppBarSection { tapCloseButton() } + } + + verifyEmptyDrafts() + } + + @Test + @TestId("207360") + fun testInvalidCcAddressDoesNotTriggerDraftCreation() { + composerRobot { + toRecipientSection { expandCcAndBccFields() } + ccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) } + + topAppBarSection { tapCloseButton() } + } + + verifyEmptyDrafts() + } + + @Test + @TestId("207361") + fun testInvalidBccAddressDoesNotTriggerDraftCreation() { + composerRobot { + toRecipientSection { expandCcAndBccFields() } + bccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) } + + topAppBarSection { tapCloseButton() } + } + + verifyEmptyDrafts() + } + + @Test + @TestId("207362") + fun testInvalidAddressesDoNotTriggerDraftCreation() { + composerRobot { + toRecipientSection { + typeRecipient(invalidEmailAddress, autoConfirm = true) + expandCcAndBccFields() + } + + ccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) } + bccRecipientSection { typeRecipient(invalidEmailAddress, autoConfirm = true) } + + topAppBarSection { tapCloseButton() } + } + + verifyEmptyDrafts() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt new file mode 100644 index 0000000000..4b8f440a35 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsMainTests.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerDraftsMainTests : MockedNetworkTest(), ComposerDraftsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val subject = "A subject!" + private val messageBody = "sample body" + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("190295", "207357") + fun testNoDraftSavedUponComposerExit() { + composerRobot { topAppBarSection { tapCloseButton() } } + verifyEmptyDrafts() + } + + @Test + @TestId("190296", "207367") + fun testDraftSavedWithSubjectOnlyUponEmptyBody() { + composerRobot { + prepareDraft(toRecipients = emptyList(), subject = subject, body = null) + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(ParticipantEntry.NoRecipient, subject = subject) + } + + @Test + @TestId("190297") + fun testDraftSavedWhenBodyIsPopulated() { + composerRobot { + prepareDraft(toRecipients = emptyList(), body = "sample body") + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(ParticipantEntry.NoRecipient, body = messageBody) + } + + @Test + @TestId("190298") + fun testDraftSavedWhenAllFieldsArePopulated() { + val participant = "test@example.com" + + composerRobot { + prepareDraft(toRecipient = participant, subject = subject, body = messageBody) + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(participant, subject = subject, body = messageBody) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt new file mode 100644 index 0000000000..afa15459d2 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsSendButtonTests.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerDraftsSendButtonTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerDraftsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("222787") + fun checkComposerSendButtonEnabledUponOpeningAValidDraft() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher { + addMockRequests( + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_222787.json" + withStatusCode 200, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_222787.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { navigateTo(Destination.Drafts) } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + + topAppBarSection { verify { isSendButtonEnabled() } } + } + } + + @Test + @TestId("222787/2", "222788") + fun checkComposerSendButtonDisabledUponRemovingRecipientFromValidDraft() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher { + addMockRequests( + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_222787.json" + withStatusCode 200, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_222787.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { navigateTo(Destination.Drafts) } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + + toRecipientSection { deleteChipAt(position = 0) } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt new file mode 100644 index 0000000000..211632b212 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsTests.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.drafts + +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.MailboxType +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot + +internal interface ComposerDraftsTests : ComposerTests { + + fun verifyEmptyDrafts() { + menuRobot { + openSidebarMenu() + openDrafts() + } + + mailboxRobot { + topAppBarSection { verify { isMailbox(MailboxType.Drafts) } } + emptyListSection { verify { isShown() } } + } + } + + fun verifyDraftCreation(vararg recipients: String, subject: String = "", body: String = "") { + val participants = recipients.map { ParticipantEntry.WithParticipant(it) } + verifyDraftCreation(expectedRecipients = participants, subject = subject, body = body) + } + + fun verifyDraftCreation(vararg expectedRecipient: ParticipantEntry, subject: String = "", body: String = "") { + verifyDraftCreation(expectedRecipient.toList(), subject = subject, body = body) + } + + fun verifyDraftCreation(expectedRecipient: String, subject: String = "", body: String = "") { + verifyDraftCreation( + expectedRecipients = listOf( + ParticipantEntry.WithParticipant(expectedRecipient) + ), + subject = subject, + body = body + ) + } + + fun verifyDraftCreation( + expectedRecipients: List, + subject: String = "", + body: String = "" + ) { + val expectedDraftItem = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.Draft, + participants = expectedRecipients, + date = "Jul 1, 2023", + subject = subject + ) + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftSaved) } } + } + + menuRobot { + openSidebarMenu() + openDrafts() + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(expectedDraftItem) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt new file mode 100644 index 0000000000..ab885172ff --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/drafts/ComposerDraftsValidRecipientsTests.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerDraftsValidRecipientsTests : MockedNetworkTest(), ComposerDraftsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val validToRecipient = "a@b.c" + private val validCcRecipient = "d@e.f" + private val validBccRecipient = "g@h.i" + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("207363") + fun testValidToAddressDoesTriggerDraftCreation() { + composerRobot { + prepareDraft(toRecipients = listOf(validToRecipient)) + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(validToRecipient) + } + + @Test + @TestId("207364") + fun testValidCcAddressDoesTriggerDraftCreation() { + composerRobot { + prepareDraft(ccRecipients = listOf(validCcRecipient)) + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(validCcRecipient) + } + + @Test + @TestId("207365") + fun testValidBccAddressDoesTriggerDraftCreation() { + composerRobot { + prepareDraft(bccRecipients = listOf(validBccRecipient)) + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(validBccRecipient) + } + + @Test + @TestId("207366") + fun testValidAddressesDoTriggerDraftCreation() { + composerRobot { + prepareDraft( + toRecipients = listOf(validToRecipient), + ccRecipients = listOf(validCcRecipient), + bccRecipients = listOf(validBccRecipient) + ) + + topAppBarSection { tapCloseButton() } + } + + verifyDraftCreation(validToRecipient, validCcRecipient, validBccRecipient, subject = "", body = "") + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt new file mode 100644 index 0000000000..c14181b219 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderExternalUserTests.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sender + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntry +import ch.protonmail.android.uitest.robot.composer.section.changeSenderBottomSheet +import ch.protonmail.android.uitest.robot.composer.section.senderSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerSenderExternalUserTests : + MockedNetworkTest(loginType = LoginTestUserTypes.External.StrangeWalrus), + ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedPrimaryAddress = "strangewalrus@proton.black" + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("192120") + fun testExternalAddressCannotBeSetAsSender() { + val expectedEntries = arrayOf( + ChangeSenderEntry(index = 0, address = expectedPrimaryAddress), + ChangeSenderEntry(index = 1, address = "strangewalrus@pm.me.proton.black"), + ChangeSenderEntry(index = 2, address = "strangewalrus@example.com", isEnabled = false) + ) + + composerRobot { + senderSection { verify { hasValue(expectedPrimaryAddress) } } + + senderSection { tapChangeSender() } + changeSenderBottomSheet { + verify { hasEntries(*expectedEntries) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt new file mode 100644 index 0000000000..bb974bb7a7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderFreeUserTests.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sender + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.senderSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerSenderFreeUserTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Free.SleepyKoala), + ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun navigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @TestId("192116") + fun testMainSenderForFreeUser() { + val expectedAddress = "sleepykoala@proton.black" + + composerRobot { + senderSection { verify { hasValue(expectedAddress) } } + } + } + + @Test + @SmokeTest + @TestId("192118") + fun testFreeUserCannotChangeSender() { + composerRobot { + senderSection { tapChangeSender() } + snackbarSection { verify { isDisplaying(ComposerSnackbar.UpgradePlanToChangeSender) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt new file mode 100644 index 0000000000..a7af033f6a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sender/ComposerSenderPaidUserTests.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sender + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.keyboardSection +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntry +import ch.protonmail.android.uitest.robot.composer.section.changeSenderBottomSheet +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.senderSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerSenderPaidUserTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedPrimaryAddress = "fancycapybara@proton.black" + + @Test + @TestId("192115") + fun testMainSenderForPaidUser() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + senderSection { verify { hasValue(expectedPrimaryAddress) } } + } + } + + @Test + @SmokeTest + @TestId("192117", "192119", "192123") + fun testMultipleAliasForPaidUser() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher { + addMockRequests( + get("/core/v4/addresses") + respondWith "/core/v4/addresses/addresses_192117.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedEntries = arrayOf( + ChangeSenderEntry(index = 0, address = "fancycapybara@proton.black"), + ChangeSenderEntry(index = 1, address = "shortcapybara@pm.me.proton.black"), + ChangeSenderEntry(index = 2, address = "fancycapybara@pm.me.proton.black"), + ChangeSenderEntry(index = 3, address = "fancynotenabled@proton.black", isEnabled = false) + ) + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + senderSection { verify { hasValue(expectedPrimaryAddress) } } + + senderSection { tapChangeSender() } + changeSenderBottomSheet { + verify { hasEntries(*expectedEntries) } + } + } + } + + @Test + @TestId("192122") + fun testPrimaryAddressIsStillDefaultAfterDraftIsSaved() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher { + addMockRequests( + get("/core/v4/addresses") + respondWith "/core/v4/addresses/addresses_192122.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedSecondaryAddress = "shortcapybara@pm.me.proton.black" + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + senderSection { verify { hasValue(expectedPrimaryAddress) } } + + senderSection { tapChangeSender() } + changeSenderBottomSheet { tapEntryAt(position = 1) } + senderSection { verify { hasValue(expectedSecondaryAddress) } } + + messageBodySection { typeMessageBody("Test value") } + keyboardSection { dismissKeyboard() } + topAppBarSection { tapCloseButton() } + } + + mailboxRobot { + topAppBarSection { tapComposerIcon() } + } + + composerRobot { + senderSection { verify { hasValue(expectedPrimaryAddress) } } + } + } + + @Test + @TestId("192125") + fun testSenderBottomSheetSwipeDismissal() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + keyboardSection { dismissKeyboard() } + senderSection { tapChangeSender() } + + changeSenderBottomSheet { + dismiss() + verify { isHidden() } + } + + senderSection { verify { hasValue(expectedPrimaryAddress) } } + } + } + + @Test + @TestId("192126") + fun testSenderBottomSheetTapDismissal() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + keyboardSection { dismissKeyboard() } + senderSection { tapChangeSender() } + changeSenderBottomSheet { verify { isShown() } } + + senderSection { tapChangeSender() } + changeSenderBottomSheet { verify { isHidden() } } + senderSection { verify { hasValue(expectedPrimaryAddress) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt new file mode 100644 index 0000000000..6017ab548e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendButtonTests.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerSendButtonTests : MockedNetworkTest(), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val placeholderString = "Random text" + + @Before + fun setMockDispatcher() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + } + + @Test + @SmokeTest + @TestId("216681") + fun checkComposerSendButtonDisabledUponOpening() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216682") + fun checkComposerSendButtonDisabledUponOnlySubjectAdded() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + subjectSection { typeSubject(placeholderString) } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216683") + fun checkComposerSendButtonDisabledUponOnlyMessageBodyAdded() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + messageBodySection { typeMessageBody(placeholderString) } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216684") + fun checkComposerSendButtonDisabledUponSubjectAndMessageBodyAdded() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + subjectSection { typeSubject(placeholderString) } + messageBodySection { typeMessageBody(placeholderString) } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216685") + fun checkComposerSendButtonDisabledUponPressingBackspaceInRecipientFields() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { tapBackspace() } + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216686") + fun checkComposerSendButtonDisabledUponAddingAndRemovingRecipients() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { typeRecipient("someone@proton.me", autoConfirm = true) } + topAppBarSection { verify { isSendButtonEnabled() } } + + toRecipientSection { deleteChipAt(0) } + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216687") + fun checkComposerSendButtonDisabledUponAddingAndDeletingInvalidRecipient() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { + typeRecipient("proton.me", autoConfirm = true) + deleteChipAt(0) + } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216688") + fun checkComposerSendButtonDisabledUponAddingAnInvalidRecipient() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { typeRecipient("proton.me", autoConfirm = true) } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("216689") + fun checkComposerSendButtonDisabledUponAddingMultipleRecipientsWithOneInvalid() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { typeMultipleRecipients("proton.me", "test@proton.me") } + + topAppBarSection { verify { isSendButtonDisabled() } } + } + } + + @Test + @TestId("268527") + fun checkConfirmationDialogIsShownWhenSubjectIsEmptyAndSendButtonClicked() { + navigator { navigateTo(Destination.Composer) } + + composerRobot { + toRecipientSection { typeRecipient("test@proton.me") } + messageBodySection { typeMessageBody(placeholderString) } + + topAppBarSection { verify { isSendButtonEnabled() } } + topAppBarSection { tapSendButton() } + + composerAlertDialogSection { + verify { + isSendWithEmptySubjectDialogDisplayed() + } + + clickSendWithEmptySubjectDialogDismissButton() + + verify { + isSendWithEmptySubjectDialogDismissed() + } + } + + subjectSection { + verify { hasEmptySubject() } + + verify { hasFocus() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt new file mode 100644 index 0000000000..e3b735b752 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToExternalUserTests.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.util.StringUtils +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.After +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageToExternalUserTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val externalUser = "test@example.com" + private val subject = "A subject" + private val baseMessageBody = "A message body" + + @Before + fun setupAndNavigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) + + navigator { navigateTo(Destination.Composer) } + } + + @After + fun verifyMessageSent() { + mailboxRobot { + snackbarSection { + verify { + isDisplaying(ComposerSnackbar.SendingMessage) + isDisplaying(ComposerSnackbar.MessageSent) + } + } + } + } + + @Test + @SmokeTest + @TestId("216690") + fun testMessageSendingToExternalUser() { + composerRobot { + prepareDraft(externalUser, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + + @Test + @TestId("216728") + fun testMessageSendingToExternalUseWithNoBody() { + composerRobot { + prepareDraft(externalUser, subject = subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216732") + fun testMessageSendingToExternalUseWithNoBodyOrSubject() { + composerRobot { + prepareDraft(externalUser) + topAppBarSection { tapSendButton() } + composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() } + } + } + + @Test + @TestId("216692") + fun testMessageSendingCcExternalUser() { + composerRobot { + prepareDraft(ccRecipient = externalUser, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216693") + fun testMessageSendingBccExternalUser() { + composerRobot { + prepareDraft(bccRecipient = externalUser, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("219564") + fun testMessageSendingLongBodyToExternalUser() { + val body = StringUtils.generateRandomString(length = 15000) + + composerRobot { + prepareDraft(bccRecipient = externalUser, subject = subject, body = body) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("219639") + fun testMessageSendingWithEmojisAsSubjectToExternalUser() { + val emojiSubject = "😖😫😩🥺😢😭😮‍💨😤😠😡" + + composerRobot { + prepareDraft(bccRecipient = externalUser, subject = emojiSubject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt new file mode 100644 index 0000000000..d6ae4f14f9 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleExternalTests.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.After +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageToMultipleExternalTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val externalRecipientTo = "test@example.com" + private val externalRecipientCc = "test2@example.com" + private val externalRecipientBcc = "test3@example.com" + private val mergedRecipients = listOf(externalRecipientTo, externalRecipientCc, externalRecipientBcc) + private val subject = "A subject" + private val baseMessageBody = "A message body" + + @Before + fun setupAndNavigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) + + navigator { navigateTo(Destination.Composer) } + } + + @After + fun verifyMessageSent() { + mailboxRobot { + snackbarSection { + verify { + isDisplaying(ComposerSnackbar.SendingMessage) + isDisplaying(ComposerSnackbar.MessageSent) + } + } + } + } + + @Test + @SmokeTest + @TestId("216696") + fun testMessageSendingToMultipleExternalUsers() { + composerRobot { + prepareDraft(mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216729") + fun testMessageSendingToMultipleExternalUsersWithNoBody() { + composerRobot { + prepareDraft(mergedRecipients, subject = subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216697") + fun testMessageSendingCcMultipleExternalUsers() { + composerRobot { + prepareDraft(ccRecipients = mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216698") + fun testMessageSendingBccMultipleExternalUsers() { + composerRobot { + prepareDraft(bccRecipients = mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216702") + fun testMessageSendingToAndCcExternalUsers() { + composerRobot { + prepareDraft( + externalRecipientTo, + ccRecipient = externalRecipientCc, + subject = subject, + body = baseMessageBody + ) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216705") + fun testMessageSendingToAndBccExternalUsers() { + composerRobot { + prepareDraft( + externalRecipientTo, + bccRecipient = externalRecipientBcc, + subject = subject, + body = baseMessageBody + ) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216707") + fun testMessageSendingToAndCcAndBccExternalUsers() { + composerRobot { + prepareDraft(externalRecipientTo, externalRecipientCc, externalRecipientBcc, subject, baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216730") + fun testMessageSendingToAndCcAndBccExternalUsersWithNoBody() { + composerRobot { + prepareDraft(externalRecipientTo, externalRecipientCc, externalRecipientBcc, subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216714") + fun testMessageSendingToAndCcAndBccMultipleExternalUsers() { + val toRecipients = listOf(externalRecipientTo, "test4@example.com") + val ccRecipients = listOf(externalRecipientCc, "test5@example.com") + val bccRecipients = listOf(externalRecipientBcc, "test6@example.com") + + composerRobot { + prepareDraft(toRecipients, ccRecipients, bccRecipients, subject, baseMessageBody) + topAppBarSection { tapSendButton() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt new file mode 100644 index 0000000000..638d85c7d5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToMultipleProtonTests.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.After +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageToMultipleProtonTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val protonRecipientTo = "royalcat@proton.black" + private val protonRecipientCc = "royaldog@proton.black" + private val protonRecipientBcc = "specialfox@proton.black" + private val mergedRecipients = listOf(protonRecipientTo, protonRecipientCc, protonRecipientBcc) + private val subject = "A subject" + private val baseMessageBody = "A message body" + + @Before + fun setupAndNavigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) + + navigator { navigateTo(Destination.Composer) } + } + + @After + fun verifyMessageSent() { + mailboxRobot { + snackbarSection { + verify { + isDisplaying(ComposerSnackbar.SendingMessage) + isDisplaying(ComposerSnackbar.MessageSent) + } + } + } + } + + @Test + @SmokeTest + @TestId("216699") + fun testMessageSendingToMultipleProtonUsers() { + composerRobot { + prepareDraft(mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216636") + fun testMessageSendingToMultipleProtonUsersWithNoBody() { + composerRobot { + prepareDraft(mergedRecipients, subject = subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216723") + fun testMessageSendingToMultipleProtonUsersWithNoSubjectOrBody() { + composerRobot { + prepareDraft(toRecipients = mergedRecipients) + topAppBarSection { tapSendButton() } + composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() } + } + } + + @Test + @TestId("216700") + fun testMessageSendingCcMultipleProtonUsers() { + composerRobot { + prepareDraft(ccRecipients = mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216701") + fun testMessageSendingBccMultipleProtonUsers() { + composerRobot { + prepareDraft(bccRecipients = mergedRecipients, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216703") + fun testMessageSendingToAndCcProtonUsers() { + composerRobot { + prepareDraft(protonRecipientTo, ccRecipient = protonRecipientCc, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216704") + fun testMessageSendingToAndBccProtonUsers() { + composerRobot { + prepareDraft( + protonRecipientTo, + bccRecipient = protonRecipientBcc, + subject = subject, + body = baseMessageBody + ) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216706") + fun testMessageSendingToAndCcAndBccProtonUsers() { + composerRobot { + prepareDraft(protonRecipientTo, protonRecipientCc, protonRecipientBcc, subject, baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216724") + fun testMessageSendingToAndCcAndBccProtonUsersWithNoBody() { + composerRobot { + prepareDraft(protonRecipientTo, protonRecipientCc, protonRecipientBcc, subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216714") + fun testMessageSendingToAndCcAndBccMultipleProtonUsers() { + val toRecipients = listOf(protonRecipientTo, "sleepykoala@proton.black") + val ccRecipients = listOf(protonRecipientCc, "happyllama@proton.black") + val bccRecipients = listOf(protonRecipientBcc, "strangewalrus@proton.black") + + composerRobot { + prepareDraft(toRecipients, ccRecipients, bccRecipients, subject, baseMessageBody) + topAppBarSection { tapSendButton() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt new file mode 100644 index 0000000000..60593cc1ec --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/ComposerSendMessageToProtonTests.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.composerAlertDialogSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.util.StringUtils +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.After +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageToProtonTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val protonRecipient = "royalcat@proton.black" + private val subject = "A subject" + private val baseMessageBody = "A message body" + + @Before + fun setupAndNavigateToComposer() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) + + navigator { navigateTo(Destination.Composer) } + } + + @After + fun verifyMessageSent() { + mailboxRobot { + snackbarSection { + verify { + isDisplaying(ComposerSnackbar.SendingMessage) + isDisplaying(ComposerSnackbar.MessageSent) + } + } + } + } + + @Test + @SmokeTest + @TestId("216691", "219591") + fun testMessageSendingToProtonUser() { + composerRobot { + prepareDraft(protonRecipient, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216691/2", "216722") + fun testMessageSendingToProtonUserWithNoBody() { + composerRobot { + prepareDraft(protonRecipient, subject = subject) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216691/3", "219632") + fun testMessageSendingToProtonUserWithNoBodyOrSubject() { + composerRobot { + prepareDraft(protonRecipient) + topAppBarSection { tapSendButton() } + composerAlertDialogSection { clickSendWithEmptySubjectDialogConfirmButton() } + } + } + + @Test + @TestId("216694") + fun testMessageSendingCcProtonUser() { + composerRobot { + prepareDraft(ccRecipient = protonRecipient, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216695") + fun testMessageSendingBccProtonUser() { + composerRobot { + prepareDraft(bccRecipient = protonRecipient, subject = subject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("216720") + fun testMessageSendingLongBodyToProtonUser() { + val body = StringUtils.generateRandomString(length = 15000) + + composerRobot { + prepareDraft(bccRecipient = protonRecipient, subject = subject, body = body) + topAppBarSection { tapSendButton() } + } + } + + @Test + @TestId("219635") + fun testMessageSendingWithEmojisAsSubjectToProtonUser() { + val emojiSubject = "😖😫😩🥺😢😭😮‍💨😤😠😡" + + composerRobot { + prepareDraft(bccRecipient = protonRecipient, subject = emojiSubject, body = baseMessageBody) + topAppBarSection { tapSendButton() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt new file mode 100644 index 0000000000..a99d244cb7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/errors/ComposerSendMessageNetworkErrors.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending.errors + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.put +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.simulateNoNetwork +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.every +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Ignore +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@TestingNotes("Scope to be expanded once MAILANDR-988 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ComposerSendMessageNetworkErrors : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val subject = "A subject" + private val messageBody = "A message body" + + @Test + @TestId("219650") + fun testMessageSendingErrorOnKeyFetching() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultRecipientKeys = false + ) { + addMockRequests( + get("/core/v4/keys") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 serveOnce true ignoreQueryParams true + ) + } + + val recipients = listOf("example@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @TestId("219651") + fun testMessageSendingErrorOnMultipleKeysFetching() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = true + ) { + addMockRequests( + get("/core/v4/keys?Email=royalcat%40proton.black") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 serveOnce true withPriority MockPriority.Highest + ) + } + + val recipients = listOf("royaldog@proton.black", "royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @TestId("219652") + @Ignore("To be enabled again when MAILANDR-989 is addressed.") + fun testMessageSendingErrorOnInvalidKeyFetched() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = false, + useDefaultRecipientKeys = false + ) { + addMockRequests( + get("/core/v4/keys?Email=royalcat%40proton.black") + respondWith "/core/v4/keys/keys_219652.json" + withStatusCode 200 serveOnce true + ) + } + + val recipients = listOf("royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @TestId("219653") + @Ignore("To be enabled again when MAILANDR-989 is addressed.") + fun testMessageSendingErrorOnMultipleInvalidKeysFetched() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + get("/core/v4/keys?Email=royalcat%40proton.black") + respondWith "/core/v4/keys/keys_219653.json" + withStatusCode 200 serveOnce true withPriority MockPriority.Highest + ) + } + + val recipients = listOf("royalcat@proton.black", "royaldog@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @TestId("222470") + fun testMessageSendingWhenOffline() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = false, + useDefaultSendMessageResponse = false, + useDefaultRecipientKeys = false + ) { + addMockRequests( + post("/mail/v4/messages") + simulateNoNetwork true ignoreQueryParams true serveOnce true, + put("/mail/v4/messages") + simulateNoNetwork true ignoreQueryParams true, + post("/mail/v4/messages/*") + simulateNoNetwork true matchWildcards true serveOnce true, + get("/core/v4/keys") + simulateNoNetwork true ignoreQueryParams true + ) + } + + val recipients = listOf("royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + every { networkManager.isConnectedToNetwork() } returns false + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageQueued) } } + } + } + + @Test + @SmokeTest + @TestId("219655") + fun testMessageSendingWithServerErrorOnLastPost() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = true, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + post("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + val recipients = listOf("royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.SendingMessage) } } + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @Ignore("To be enabled again when MAILANDR-1244 is addressed.") + @TestId("219656") + fun testMessageSendingWithServerErrorOnDraftUpload() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = false, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + post("/mail/v4/messages") + respondWith "/mail/v4/messages/post/post_messages_base_create_placeholder.json" + withStatusCode 200 serveOnce true, + put("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + val recipients = listOf("royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.MessageSentError) } } + } + } + + @Test + @Ignore("To be enabled again when MAILANDR-1244 is addressed.") + @TestId("219657") + fun testMessageSendingWithServerErrorOnDraftCreation() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultDraftUploadResponse = false, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + post("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503, + put("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + val recipients = listOf("royalcat@proton.black") + + navigator { navigateTo(Destination.Composer) } + + composerRobot { + prepareDraft(toRecipients = recipients, subject = subject, body = messageBody) + topAppBarSection { tapSendButton() } + } + + mailboxRobot { + snackbarSection { + verify { + isDisplaying(ComposerSnackbar.SendingMessage) + isDisplaying(ComposerSnackbar.MessageSentError) + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt new file mode 100644 index 0000000000..5cd276a14a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/sending/reply/ComposerReplyConversationTests.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.sending.reply + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.put +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline.EmbeddedImagesTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerReplyConversationTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + ComposerTests, + EmbeddedImagesTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @SdkSuppress(minSdkVersion = 29) + @TestId("260089") + fun testReplyToMessageWithEmbeddedImagesWithConversationModeEnabled() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultMessagesList = false, + useDefaultDraftUploadResponse = false, + useDefaultSendMessageResponse = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_260089.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_260089.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_260089.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_260089_2.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_260089.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_260089" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream, + // Draft creation + post("/mail/v4/messages") + respondWith "/mail/v4/messages/post/post_messages_260089.json" + withStatusCode 200 serveOnce true, + // Draft upload (1st) + put("/mail/v4/messages/*") + respondWith "/mail/v4/messages/put/put_messages_260089.json" + withStatusCode 200 matchWildcards true serveOnce true, + // Final draft upload + put("/mail/v4/messages/*") + respondWith "/mail/v4/messages/put/put_messages_260089_2.json" + withStatusCode 200 matchWildcards true serveOnce true, + // Actual sending + post("/mail/v4/messages/*") + respondWith "/mail/v4/messages/post/post_messages_260089_2.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) } + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + + messageHeaderSection { tapReplyButton() } + } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + + messageBodySection { typeMessageBody("Reply") } + topAppBarSection { tapSendButton() } + + snackbarSection { + verify { isDisplaying(ComposerSnackbar.SendingMessage) } + verify { isDisplaying(ComposerSnackbar.MessageSent) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt new file mode 100644 index 0000000000..4bd8e004cb --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/composer/subject/ComposerSubjectTests.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.composer.subject + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.e2e.composer.ComposerTests +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ComposerSubjectTests : MockedNetworkTest(), ComposerTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun prelude() { + mockWebServer.dispatcher combineWith composerMockNetworkDispatcher() + navigator { navigateTo(Destination.Composer) } + } + + @Test + @SmokeTest + @TestId("194249", "194250", "194251") + fun testRomanNumbersSymbolsCharInputInSubject() { + val expectedInput = (RomanLettersRange + RomanNumbersRange + SymbolsRange).joinToString(separator = "") + + composerRobot { + typeAndVerifySubject(expectedInput) + } + } + + @Test + @TestId("194252") + fun testNonRomanLettersAndNumbersCharInputInSubject() { + composerRobot { + typeAndVerifySubject(NonRomanChars) + } + } + + @Test + @TestId("194253") + fun testEmojiInputInSubject() { + composerRobot { + typeAndVerifySubject(Emojis) + } + } + + @Test + @TestId("194254", "194255") + fun testImeActionInSubject() { + composerRobot { + subjectSection { + typeSubject(DefaultSubject) + performImeAction() + } + + messageBodySection { verify { hasFocus() } } + } + } + + @Test + @TestId("194260") + fun testVeryLongSubjectField() { + val expectedInput = StringBuilder().apply { + repeat(times = 50) { append(RomanLettersRange + RomanNumbersRange + SymbolsRange) } + }.toString() + + composerRobot { + typeAndVerifySubject(expectedInput) + } + } + + @Test + @TestId("194265") + fun testDeletionInSubject() { + composerRobot { + subjectSection { + typeSubject(DefaultSubject) + clearField() + + verify { hasEmptySubject() } + } + } + } + + @Test + @SmokeTest + @TestId("194273") + fun testSubjectFieldOnActivityRecreation() { + composerRobot { + subjectSection { + typeSubject(DefaultSubject) + } + } + + deviceRobot { triggerActivityRecreation() } + + composerRobot { + subjectSection { + verify { hasSubject(DefaultSubject) } + } + } + } + + private fun ComposerRobot.typeAndVerifySubject(expectedInput: String) { + subjectSection { + typeSubject(expectedInput) + verify { hasSubject(expectedInput) } + } + } + + private companion object { + + val RomanLettersRange = 'A'.rangeTo('z').filter { it.isLetter() } + val RomanNumbersRange = 0..9 + val SymbolsRange = '!'.rangeTo('~').filterNot { it.isLetterOrDigit() } + const val NonRomanChars = "ςερτυθιοπασδφγηξκλζχψωβνμ" + const val Emojis = "😡😦🥶👻👍" + + const val DefaultSubject = "Subject!!" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt new file mode 100644 index 0000000000..ccf98f064b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/LoginFlowTests.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.login + +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import ch.protonmail.android.uitest.BaseTest +import ch.protonmail.android.uitest.di.LocalhostApi +import ch.protonmail.android.uitest.di.LocalhostApiModule +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import me.proton.core.auth.test.MinimalSignInInternalTests +import me.proton.core.auth.test.rule.AcceptExternalRule +import me.proton.core.auth.test.usecase.WaitForPrimaryAccount +import me.proton.core.network.domain.client.ExtraHeaderProvider +import me.proton.core.test.quark.Quark +import me.proton.core.test.quark.data.User +import org.junit.Rule +import javax.inject.Inject + +@CoreLibraryTest +@HiltAndroidTest +@UninstallModules(LocalhostApiModule::class) +internal class LoginFlowTests : BaseTest(), MinimalSignInInternalTests { + + @JvmField + @BindValue + @LocalhostApi + val localhostApi = false + + override val quark: Quark = BaseTest.quark + override val users: User.Users = BaseTest.users + + @get:Rule(order = RuleOrder_21_Injected) + val acceptExternalRule = AcceptExternalRule { extraHeaderProvider } + + @Inject + lateinit var extraHeaderProvider: ExtraHeaderProvider + + @Inject + lateinit var waitForPrimaryAccount: WaitForPrimaryAccount + + override fun verifyAfter() { + waitForPrimaryAccount() + + mailboxRobot { verify { isShown() } } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt new file mode 100644 index 0000000000..2cd151e6c5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/login/StartupTests.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.login + +import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginType +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.verify +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +internal open class StartupTests : MockedNetworkTest(loginType = LoginType.LoggedOut) { + + @Test + @TestId("224907") + fun testBackButtonNavigationOnMainScreen() { + // Do not use default values, we only need to serve the sessions API call. + mockWebServer.dispatcher = MockNetworkDispatcher().apply { + addMockRequests( + post("/auth/v4/sessions") + respondWith "/auth/v4/sessions/sessions_logged_out_placeholder.json" + withStatusCode 200 + ) + } + + navigator { openApp() } + + deviceRobot { + pressBack() + + verify { isMainActivityNotDisplayed() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt new file mode 100644 index 0000000000..4f46412f56 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/ConversationMarkAsReadTests.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationMarkAsReadTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("78994") + fun checkConversationMarkedAsReadWhenLastUnreadMessageIsOpened() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_78994.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_78994.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_78994.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_78994.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Third message" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + + // Idling is currently not automatically handled when coming from UI Automator interactions. + uiDevice.pressBack().also { ComposeTestRuleHolder.rule.waitForIdle() } + + mailboxRobot { + listSection { verify { readItemAtPosition(0) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt new file mode 100644 index 0000000000..7039dc289c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxAuthenticityBadgeTests.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.MailboxType +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class MailboxAuthenticityBadgeTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val mailboxItemProtonOfficial = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("P"), + participants = listOf(ParticipantEntry.Common.ProtonOfficial), + subject = "Official message", + date = "Jul 5, 2023" + ) + + private val mailboxItemProtonUnofficial = MailboxListItemEntry( + index = 1, + avatarInitial = AvatarInitial.WithText("P"), + participants = listOf(ParticipantEntry.Common.ProtonUnofficial), + subject = "Not official message", + date = "Jul 5, 2023" + ) + + private val mailboxItemLongNameOfficial = mailboxItemProtonOfficial.copy( + participants = listOf( + ParticipantEntry.WithParticipant( + name = "ProtonProtonProtonProtonProtonProtonProtonProton", + isProton = true + ) + ) + ) + + @Test + @SmokeTest + @TestId("192128", "192129") + fun testAuthenticityBadgeInMailboxInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192128.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_192128.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + val expectedItems = arrayOf(mailboxItemProtonOfficial, mailboxItemProtonUnofficial) + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { + verify { listItemsAreShown(*expectedItems) } + } + } + } + + @Test + @TestId("194274") + fun testAuthenticityBadgeWithLongNameInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_194274.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194274.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { + verify { listItemsAreShown(mailboxItemLongNameOfficial) } + } + } + } + + @Test + @TestId("192130", "192131", "192132", "192133") + fun testAuthenticityBadgeInSentFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192130.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=7&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_192130.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedItems = arrayOf( + mailboxItemProtonUnofficial.copy( + index = 0, + participants = listOf(ParticipantEntry.Common.ProtonUnofficial, ParticipantEntry.Common.FreeUser) + ), + mailboxItemProtonOfficial.copy( + index = 1, + participants = listOf(ParticipantEntry.Common.ProtonOfficial, ParticipantEntry.Common.FreeUser) + ), + mailboxItemProtonUnofficial.copy(index = 2), + mailboxItemProtonOfficial.copy(index = 3) + ) + + navigator { navigateTo(Destination.Inbox) } + + menuRobot { + openSidebarMenu() + openSent() + } + + mailboxRobot { + topAppBarSection { verify { isMailbox(MailboxType.Sent) } } + + listSection { + verify { listItemsAreShown(*expectedItems) } + } + } + } + + @Test + @SmokeTest + @TestId("192134", "192135") + fun testAuthenticityBadgeInMailboxInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192134.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_192134.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + val expectedItems = arrayOf(mailboxItemProtonOfficial, mailboxItemProtonUnofficial) + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { + verify { listItemsAreShown(*expectedItems) } + } + } + } + + @Test + @TestId("194275") + fun testAuthenticityBadgeWithLongNameInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_194275.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_194275.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { + verify { listItemsAreShown(mailboxItemLongNameOfficial) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt new file mode 100644 index 0000000000..da7e1e4d8f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxFlowTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.mailbox.MailboxType +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.stickyHeaderSection +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class MailboxFlowTest : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun setupDispatcher() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher() + navigator { navigateTo(Destination.Inbox) } + } + + @Test + fun openMailboxAndSwitchLocation() { + mailboxRobot { + verify { isShown() } + } + + menuRobot { + openSidebarMenu() + openDrafts() + } + + mailboxRobot { + topAppBarSection { verify { isMailbox(MailboxType.Drafts) } } + } + + menuRobot { + openSidebarMenu() + openAllMail() + } + + mailboxRobot { + topAppBarSection { verify { isMailbox(MailboxType.AllMail) } } + } + } + + /* + * This could be improved by injecting an account with some known messages and performing + * verifications on the messages list to ensure filtering actually works as expected + */ + @Test + fun filterUnreadMessages() { + mailboxRobot { + verify { isShown() } + + stickyHeaderSection { + verify { unreadFilterIsDisplayed() } + filterUnreadMessages() + verify { unreadFilterIsSelected() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt new file mode 100644 index 0000000000..97e4f434b9 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxParticipantsTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MailboxParticipantsTest : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedInboxListEntries = arrayOf( + MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf( + ParticipantEntry.WithParticipant("mobileappsuitesting3@proton.black") + ), + subject = "Test no contact, empty sender name", + date = "Mar 20, 2023" + ), + MailboxListItemEntry( + index = 1, + avatarInitial = AvatarInitial.WithText("?"), + participants = listOf(ParticipantEntry.NoSender), + subject = "Test no contact, empty", + date = "Mar 20, 2023" + ), + MailboxListItemEntry( + index = 2, + avatarInitial = AvatarInitial.WithText("U"), + participants = listOf(ParticipantEntry.WithParticipant("UI Tests Contact 1")), + subject = "From contact with no sender name", + date = "Mar 20, 2023" + ) + ) + + @Test + @TestId("77426") + fun checkAvatarInitialsWithMissingParticipantDetailsInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultContacts = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_77426.json" + withStatusCode 200, + get("/contacts/v4/contacts") + respondWith "/contacts/v4/contacts/contacts_77426.json" + withStatusCode 200 ignoreQueryParams true, + get("/contacts/v4/contacts/emails") + respondWith "/contacts/v4/contacts/emails/contacts-emails_77426.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_77426.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(*expectedInboxListEntries) } + } + } + } + + @Test + @TestId("77427") + fun checkAvatarInitialsWithMissingParticipantDetailsInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultContacts = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_77427.json" + withStatusCode 200, + get("/contacts/v4/contacts") + respondWith "/contacts/v4/contacts/contacts_77427.json" + withStatusCode 200 ignoreQueryParams true, + get("/contacts/v4/contacts/emails") + respondWith "/contacts/v4/contacts/emails/contacts-emails_77427.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_77427.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(*expectedInboxListEntries) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt new file mode 100644 index 0000000000..2561ddcfec --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MailboxSwitchTests.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MailboxSwitchTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedFirstSentItem = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("Mobile Apps UI Testing 2")), + subject = "Test message TOP", + date = "Apr 3, 2023" + ) + + @Test + @TestId("183527") + fun checkMailboxSwitchConversationToMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_183527.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_183527.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=7&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_183527.json" + withStatusCode 200 serveOnce true withNetworkDelay 2000 + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { scrollToBottom() } + } + + menuRobot { + openSidebarMenu() + openSent() + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(expectedFirstSentItem) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt new file mode 100644 index 0000000000..3488dfa271 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/MessageLoadingTests.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageLoadingTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("66392", "225747/2") + fun checkMessageLoadedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_66392.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_66392.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_66392.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Bye and hello" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + + uiDevice.pressBack() + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + } + + @Test + @TestId("66393", "225747") + fun checkMessageLoadedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_66393.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_66393.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_66393.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_66393.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Hello once again" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + + uiDevice.pressBack() + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + } + + @Test + @TestId("66394") + fun checkLongMessageLoadedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_66394.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_66394.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_66394.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Lorem ipsum" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + + uiDevice.pressBack() + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + } + + @Test + @TestId("66395") + fun checkLongMessageLoadedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_66395.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_66395.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_66395.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_66395.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Lorem ipsum" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + + uiDevice.pressBack() + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + } + + @Test + @TestId("78993") + fun checkMostRecentUnreadMessageIsOpenedInConversation() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_78993.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_78993.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_78993.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_78993.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedMessageBody = "Third message" + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { messageInWebViewContains(expectedMessageBody) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt new file mode 100644 index 0000000000..1d85026f5f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/allmail/AllMailMailboxFolderColorsTests.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.allmail + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.MailFolderEntry +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.MenuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class AllMailMailboxFolderColorsTests : MockedNetworkTest() { + + private val menuRobot = MenuRobot() + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val firstMessageEntry = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting3")), + subject = "Parent folder message", + date = "Mar 28, 2023" + ) + + private val secondMessageEntry = MailboxListItemEntry( + index = 1, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")), + subject = "Child folder message", + date = "Mar 21, 2023" + ) + + @Test + @TestId("80673") + fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingDisabledInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80673.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80673.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_80673.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Fern) + ) + } + + @Test + @TestId("80674") + fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingDisabledInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80674.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80674.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80674.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Fern) + ) + } + + @Test + @TestId("80675") + fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingEnabledInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80675.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80675.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_80675.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot) + ) + } + + @Test + @TestId("80676") + fun checkFolderColorInAllMailWithSettingEnabledAndParentInheritingEnabledInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80676.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80676.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80676.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.WithColor.Carrot) + ) + } + + @Test + @TestId("80677") + fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingEnabledInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80677.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80677.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_80677.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor) + ) + } + + @Test + @TestId("80678") + fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingEnabledInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80678.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80678.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80678.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor) + ) + } + + @Test + @TestId("80679") + fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingDisabledInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80679.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80679.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_80679.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor) + ) + } + + @Test + @TestId("80680") + fun checkFolderColorInAllMailWithSettingDisabledAndParentInheritingDisabledInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80680.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_80680.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=5&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80680.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + verifyMailboxItems( + firstLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor), + secondLocationIcon = MailFolderEntry(index = 0, iconTint = Tint.NoColor) + ) + } + + private fun verifyMailboxItems(firstLocationIcon: MailFolderEntry, secondLocationIcon: MailFolderEntry) { + navigator { navigateTo(Destination.Inbox) } + + val expectedMailboxEntries = arrayOf( + firstMessageEntry.copy(locationIcons = listOf(firstLocationIcon)), + secondMessageEntry.copy(locationIcons = listOf(secondLocationIcon)) + ) + + menuRobot + .openSidebarMenu() + .openAllMail() + .listSection { verify { listItemsAreShown(*expectedMailboxEntries) } } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt new file mode 100644 index 0000000000..183c2e5c53 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentConversationModeTests.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Ignore +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentConversationModeTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SdkSuppress(minSdkVersion = 29) + @Ignore("To be addressed with MAILANDR-1276") + @TestId("194318") + fun testMultipleAttachmentDownloadingInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_194318.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_194318.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194318.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194318_2.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_194318" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream withNetworkDelay 150_000L + ) + } + + val expectedSnackbar = MessageDetailSnackbar.MultipleDownloadsWarning + + navigator { navigateTo(Destination.MailDetail()) } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + + verify { hasLoaderDisplayedForItem() } + } + + messageHeaderSection { collapseMessage() } + messagesCollapsedSection { openMessageAtIndex(0) } + + messageBodySection { waitUntilMessageIsShown() } + attachmentsSection { tapItem() } + + snackbarSection { + verify { isDisplaying(expectedSnackbar) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt new file mode 100644 index 0000000000..675ada4940 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDetailsMainTests.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.storage +import ch.protonmail.android.uitest.robot.helpers.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentDetailsMainTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("189705") + fun testAttachmentLoaderIconShownOnFetching() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_189705.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_189705.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000 + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderDisplayedForItem() } + } + } + } + + @Test + @SdkSuppress(minSdkVersion = 30) + @TestId("194341") + fun testAttachmentLoaderIsStillShownOnMessageReopening() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194341.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194341.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000 + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + } + + detailTopBarSection { tapBackButton() } + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + verify { hasLoaderDisplayedForItem() } + } + } + } + + @Test + @SdkSuppress(minSdkVersion = 30) + @TestId("194278") + fun testFilesArePresentInTheDownloadFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194278.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194278.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 1000L + ) + } + + val expectedImageFileName = "An attached image.jpeg" + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + + verify { + hasLoaderDisplayedForItem() + hasLoaderNotDisplayedForItem() + } + } + } + + deviceRobot { + storage { + verify { containsFileInDownloadsWithName(expectedImageFileName) } + } + } + } + + @Test + @TestId("194303", "194344") + fun testAttachmentIsNotRefetchedIfAlreadyDownloadedExists() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194303.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194303.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true serveOnce true + withNetworkDelay 1000L withMimeType MimeType.OctetStream + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { + hasLoaderDisplayedForItem() + hasLoaderNotDisplayedForItem() + } + + tapItem() + verify { hasLoaderNotDisplayedForItem() } + } + } + + deviceRobot { + intents { verify { actionViewIntentWasLaunched(times = 2) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt new file mode 100644 index 0000000000..276e4cc98d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentDownloadNotificationsTests.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.models.NotificationEntry +import ch.protonmail.android.uitest.robot.helpers.section.deviceSoftKeys +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.notificationsSection +import ch.protonmail.android.uitest.robot.helpers.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@SdkSuppress(maxSdkVersion = 31) +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentDownloadNotificationsTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedNotification = NotificationEntry( + title = "Downloading attachment", + isClearable = false + ) + + @Test + @TestId("189706") + fun testForegroundNotificationWhenAttachmentIsDownloading() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_189706.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_189706.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 150_000L + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderDisplayedForItem() } + } + } + + deviceRobot { + deviceSoftKeys { pressHomeButton() } + + notificationsSection { + verify { hasNotificationDisplayed(expectedNotification) } + } + } + } + + @Test + @TestId("189707", "194335") + fun testForegroundNotificationIsGoneAfterSuccessfulDownload() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_189707.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_189707.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true withMimeType MimeType.OctetStream withNetworkDelay 5000L + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderDisplayedForItem() } + } + } + + deviceRobot { + deviceSoftKeys { pressHomeButton() } + + notificationsSection { + verify { + hasNotificationDisplayed(expectedNotification) + hasNoNotificationsDisplayed() + } + } + + intents { + verify { actionViewIntentWasNotLaunched() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt new file mode 100644 index 0000000000..ec113604ab --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentErrorsTests.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentErrorsTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @TestId("194315", "194316") + fun testMultipleAttachmentsAreNotHandledInParallel() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194315.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194315.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream withNetworkDelay 2000L + ) + } + + val expectedSnackbar = MessageDetailSnackbar.MultipleDownloadsWarning + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem(position = 0) + verify { hasLoaderDisplayedForItem() } + + tapItem(position = 1) + } + + snackbarSection { + verify { isDisplaying(expectedSnackbar) } + waitUntilDismisses(expectedSnackbar) + } + + attachmentsSection { verify { hasLoaderNotDisplayedForItem(position = 0) } } + } + + deviceRobot { + intents { verify { actionViewIntentWasLaunched() } } + } + } + + @Test + @TestId("194354") + fun testManualDownloadRetry() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194354.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194354.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 403 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + attachmentsSection { tapItem() } + + snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToGetAttachment) } } + + attachmentsSection { tapItem() } + + deviceRobot { + intents { verify { actionViewIntentWasLaunched() } } + } + } + } + + @Test + @TestId("194355") + fun testDownloadIsNotRetriedOn403() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194355.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194355.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 403 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + attachmentsSection { tapItem() } + + snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToGetAttachment) } } + + deviceRobot { + intents { verify { actionViewIntentWasNotLaunched() } } + } + + attachmentsSection { tapItem() } + + deviceRobot { + intents { verify { actionViewIntentWasLaunched() } } + } + } + } + + @Test + @TestId("194355/2") + fun test500StatusCodeOnDownloadError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194355.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194355.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 500 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + attachmentsSection { tapItem() } + + deviceRobot { + intents { verify { actionViewIntentWasLaunched() } } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt new file mode 100644 index 0000000000..ed767d384c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMessageModeTests.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import kotlin.test.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentMessageModeTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @TestId("417285") + fun testAttachmentOpeningWithHeaderMaps() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_417285.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_417285.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_417285" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + attachmentsSection { tapItem() } + deviceRobot { + intents { verify { actionViewIntentWasLaunched(times = 1) } } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt new file mode 100644 index 0000000000..cde26f7938 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/AttachmentMultipleDownloadTests.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.helpers.section.intents +import ch.protonmail.android.uitest.robot.helpers.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class AttachmentMultipleDownloadTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SdkSuppress(minSdkVersion = 30) + @TestId("194333", "194338") + fun testMultipleAttachmentWithSequentialDownloads() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194333.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194333.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_txt" + withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream + ) + } + + val firstExpectedMimeType = "image/png" + val secondExpectedMimeType = "text/plain" + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderNotDisplayedForItem() } + } + + deviceRobot { + intents { + verify { actionViewIntentWasLaunched(mimeType = firstExpectedMimeType) } + } + } + + attachmentsSection { + tapItem(position = 1) + verify { hasLoaderNotDisplayedForItem(position = 1) } + } + + deviceRobot { + intents { + verify { + actionViewIntentWasLaunched(mimeType = secondExpectedMimeType) + } + } + } + } + } + + @Test + @TestId("194342", "194343", "194326") + fun testAttachmentsFromDifferentMessages() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_194342.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194342.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_194342_2.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_png" + withStatusCode 200 matchWildcards true serveOnce true + withNetworkDelay 150_000L withMimeType MimeType.OctetStream, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_small_jpg" + withStatusCode 200 matchWildcards true serveOnce true withMimeType MimeType.OctetStream + ) + } + + val expectedLaunchedMimeType = "image/jpeg" + val expectedNotLaunchedMimeType = "image/png" + + navigator { navigateTo(Destination.MailDetail()) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderDisplayedForItem() } + } + + detailTopBarSection { tapBackButton() } + } + + mailboxRobot { + listSection { clickMessageByPosition(position = 1) } + } + + messageDetailRobot { + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + attachmentsSection { + tapItem() + verify { hasLoaderNotDisplayedForItem() } + } + } + + deviceRobot { + intents { + verify { + actionViewIntentWasLaunched(mimeType = expectedLaunchedMimeType) + actionViewIntentWasNotLaunched(mimeType = expectedNotLaunchedMimeType) + } + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt new file mode 100644 index 0000000000..6980dba195 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/ConversationDetailEmbeddedImagesTests.kt @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailEmbeddedImagesTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + EmbeddedImagesTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @TestId("203101", "203695") + fun testConversationDetailEmbeddedImagesNotLoadedWithSettingOff() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203101.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203101.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203101.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203101.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + + bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } } + } + } + + @Test + @TestId("203102", "203108") + fun testConversationDetailEmbeddedImagesBodyLoadingError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203102.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203102.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203104.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + snackbarSection { + verify { isDisplaying(MessageDetailSnackbar.FailedToLoadMessage) } + } + } + } + + @Test + @TestId("203104", "203110") + fun testConversationDetailEmbeddedImagesBodyDecryptionError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203104.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203104.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203104.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203104.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToDecryptMessage) } } + } + } + + @Test + @SmokeTest + @SdkSuppress(minSdkVersion = 29) + @TestId("203105", "203692") + fun testConversationDetailEmbeddedImagesAreLoaded() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203105.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203105.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203105.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203105.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_203105" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + val expectedSummary = AttachmentDetailSummaryEntry(summary = "1 file", size = "1.5 kB") + val expectedEntry = AttachmentDetailItemEntry(index = 0, fileName = "image.png", fileSize = "1.5 kB") + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + + attachmentsSection { + verify { + hasSummaryDetails(expectedSummary) + hasAttachments(expectedEntry) + } + } + } + } + + @Test + @TestId("203106") + fun testConversationDetailEmbeddedImagesErrorsUponDownload() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203106.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203106.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203106.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203106.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + } + } + + @Test + @TestId("203107") + fun testConversationDetailEmbeddedImagesErrorsUponDecryption() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203107.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203107.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203107.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203107.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_203107" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + } + } + + @Test + @TestId("203696") + fun testConversationDetailEmbeddedImagesBlockedBannerIsNotDisplayedWhenNoEmbeddedImagesArePresent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203696.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203696.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203696.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203696.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @TestId("203699", "203700/2") + fun testConversationDetailEmbeddedImagesBlockedBannerIsDisplayedOnExternalEmails() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203700_2.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_203700.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_203700.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203700.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt new file mode 100644 index 0000000000..46550a4539 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/EmbeddedImagesTests.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline + +import ch.protonmail.android.uitest.robot.detail.section.MessageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify + +internal interface EmbeddedImagesTests { + + fun MessageBodySection.verifyEmbeddedImageLoaded(expectedNumber: Int = 1, expectedState: Boolean) { + waitUntilMessageIsShown() + + verify { + hasEmbeddedImages(expectedNumber) + hasEmbeddedImagesSuccessfullyLoaded(expectedState) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt new file mode 100644 index 0000000000..be368505a0 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/attachments/inline/MessageDetailEmbeddedImagesTests.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.attachments.inline + +import androidx.test.filters.SdkSuppress +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withMimeType +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry +import ch.protonmail.android.uitest.robot.detail.section.attachmentsSection +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Ignore +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailEmbeddedImagesTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + EmbeddedImagesTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @TestId("203101/2", "203694") + fun testMessageDetailEmbeddedImagesNotLoadedWithSettingOff() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203101_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203101.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203101.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + + bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } } + } + } + + @Ignore("MAILANDR-753") + @Test + @TestId("203102/2", "203108/2") + fun testMessageDetailEmbeddedImagesBodyLoadingError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203102_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203102.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToLoadMessage) } } + } + } + + @Ignore("MAILANDR-753") + @Test + @TestId("203104/2", "203110/2") + fun testMessageDetailEmbeddedImagesBodyDecryptionError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203104_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203104.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203104.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + snackbarSection { verify { isDisplaying(MessageDetailSnackbar.FailedToDecryptMessage) } } + } + } + + @Test + @SmokeTest + @SdkSuppress(minSdkVersion = 29) + @TestId("203105/2", "203693") + fun testMessageDetailEmbeddedImagesAreLoaded() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203105_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203105.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203105.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_203105" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + val expectedSummary = AttachmentDetailSummaryEntry(summary = "1 file", size = "1.5 kB") + val expectedEntry = AttachmentDetailItemEntry(index = 0, fileName = "image.png", fileSize = "1.5 kB") + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = true) } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + + attachmentsSection { + verify { + hasSummaryDetails(expectedSummary) + hasAttachments(expectedEntry) + } + } + } + } + + @Test + @TestId("203106/2") + fun testMessageDetailEmbeddedImagesErrorsUponDownload() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203106_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203106.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203106.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + } + } + + @Test + @TestId("203107/2") + fun testMessageDetailEmbeddedImagesErrorsUponDecryption() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203107_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203107.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203107.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/attachments/*") + respondWith "/mail/v4/attachments/attachment_203107" + withStatusCode 200 matchWildcards true serveOnce true + withMimeType MimeType.OctetStream + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { verifyEmbeddedImageLoaded(expectedState = false) } + } + } + + @Test + @TestId("203696/2") + fun testMessageDetailEmbeddedImagesBlockedBannerIsNotDisplayedWhenNoEmbeddedImagesArePresent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203696_2.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203696.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203696.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @TestId("203700") + fun testMessageDetailEmbeddedImagesBlockedBannerIsDisplayedOnExternalEmails() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_203700.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_203700.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_203700.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bannerSection { verify { hasBlockedEmbeddedImagesBannerDisplayed() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt new file mode 100644 index 0000000000..4119a9d88d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/AuthenticityBadgeDetailTests.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge + +import ch.protonmail.android.uitest.robot.detail.section.MessageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify + +internal interface AuthenticityBadgeDetailTests { + + fun MessageHeaderSection.verifyProtonSenderInMessageHeader( + senderName: String = "Proton", + shouldDisplayBadge: Boolean + ) { + verify { + hasSenderName(senderName) + hasAuthenticityBadge(shouldDisplayBadge) + } + + expandHeader() + + verify { + hasSenderName(senderName) + hasAuthenticityBadge(shouldDisplayBadge) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt new file mode 100644 index 0000000000..9bb02f87c2 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/ConversationDetailAuthenticityBadgeTests.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.verify +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class ConversationDetailAuthenticityBadgeTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), AuthenticityBadgeDetailTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("192140", "192141") + fun testAuthBadgeInConversationMessageHeaderWhenIsProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192140.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_192140.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_192140.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192140.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { + verifyProtonSenderInMessageHeader(shouldDisplayBadge = true) + } + } + } + + @Test + @TestId("192140/2", "192142", "192143") + fun testAuthBadgeInConversationMessageHeaderWhenIsNotProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192140.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_192142.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_192142.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192142.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { + verifyProtonSenderInMessageHeader(shouldDisplayBadge = false) + } + } + } + + @Test + @TestId("192144", "192145") + fun testAuthBadgeInConversationCollapsedExpandedHeaderWhenIsProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192144.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_192144.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_192144.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192144.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + conversationDetailRobot { + verifyCollapsedAndExpandedHeader(index = 0, shouldDisplayBadge = true) + } + } + + @Test + @TestId("192144/2", "192146", "192147") + fun testAuthBadgeInConversationCollapsedExpandedHeaderWhenIsNotProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192144.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_192146.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_192146.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192146.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + conversationDetailRobot { + verifyCollapsedAndExpandedHeader(index = 0, shouldDisplayBadge = false) + } + } + + private fun ConversationDetailRobot.verifyCollapsedAndExpandedHeader( + index: Int, + sender: String = "Proton", + shouldDisplayBadge: Boolean + ) { + + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { expanded { collapse() } } + + messagesCollapsedSection { + verify { + senderNameIsDisplayed(index, sender) + authenticityBadgeIsDisplayed(index, shouldDisplayBadge) + } + + openMessageAtIndex(index) + } + + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { + verify { + hasSenderName(sender) + hasAuthenticityBadge(shouldDisplayBadge) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt new file mode 100644 index 0000000000..c670830468 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/authbadge/MessageDetailAuthenticityBadgeTests.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.authbadge + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class MessageDetailAuthenticityBadgeTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), AuthenticityBadgeDetailTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("192136", "192137") + fun testAuthBadgeInMessageHeaderWhenIsProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192136.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_192136.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192136.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { + verifyProtonSenderInMessageHeader(shouldDisplayBadge = true) + } + } + } + + @Test + @TestId("192136/2", "192138", "192139") + fun testAuthBadgeInMessageHeaderWhenIsNotProtonOfficial() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_192136.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_192138.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_192138.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { navigateTo(Destination.MailDetail(messagePosition = 0)) } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + messageHeaderSection { + verifyProtonSenderInMessageHeader(shouldDisplayBadge = false) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt new file mode 100644 index 0000000000..806f335112 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailHtmlSanitizationTests.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailHtmlSanitizationTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("189699") + fun checkHtmlSanitizationInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_189699.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_189699.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_189699.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { hasHtmlContentSanitised() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt new file mode 100644 index 0000000000..5a831e9523 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/ConversationDetailRemoteContentTests.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailRemoteContentTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + private val expectedBodyText = "Various img elements" + private val expectedBodyTextLastMessage = "Various img elements (2)" + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @SmokeTest + @TestId("184211", "212682") + fun checkRemoteContentBlockedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184211.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_184211.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_184211.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184211.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + } + } + + @Test + @TestId("184212") + fun checkRemoteContentBlockedWithMultipleMessagesInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184212.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_184212.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_184212.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184212.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184212_2.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyTextLastMessage) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + + messageHeaderSection { + expanded { collapse() } + } + + messagesCollapsedSection { + scrollToTop() + openMessageAtIndex(0) + } + + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + } + } + + @Test + @TestId("212683/2") + fun checkRemoteContentBannerNotShownWhenNoRemoteContentIsDisplayedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212683_2.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_212683.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_212683.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212683.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @TestId("212684/2") + fun checkCombinedBannerIsShownWhenBothRemoteContentAndEmbeddedImagesAreBlockedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212684_2.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_212684.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_212684.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212684.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + hasRemoteImageLoaded(false) + hasEmbeddedImagesSuccessfullyLoaded(false) + } + } + + bannerSection { verify { hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() } } + } + } + + @Test + @TestId("212686/2", "212687") + fun checkRemoteContentBlockedFromExternalAddressInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212686_2.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_212686.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_212686.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212686.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt new file mode 100644 index 0000000000..de6755bde8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailHtmlSanitizationTests.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailHtmlSanitizationTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("189700") + fun checkHtmlSanitizationInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_189700.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_189700.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { hasHtmlContentSanitised() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt new file mode 100644 index 0000000000..c1586a8cb8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MessageDetailRemoteContentTests.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailRemoteContentTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + private val expectedBodyText = "Various img elements" + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("184210", "212681") + fun checkRemoteContentBlockedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184210.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_184210.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184210.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + } + } + + @Test + @TestId("212683") + fun checkRemoteContentBannerNotShownWhenNoRemoteContentIsDisplayedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212683.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212683.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212683.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @TestId("212684") + fun checkCombinedBannerIsShownWhenBothRemoteContentAndEmbeddedImagesAreBlockedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212684.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212684.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212684.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + hasRemoteImageLoaded(false) + hasEmbeddedImagesSuccessfullyLoaded(false) + } + } + + bannerSection { verify { hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() } } + } + } + + @Test + @TestId("212686") + fun checkRemoteContentBlockedFromExternalAddressInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_212686.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212686.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212686.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(false) + } + } + + bannerSection { verify { hasBlockedRemoteImagesBannerDisplayed() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt new file mode 100644 index 0000000000..38a7cc0f26 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/MockedDetailRemoteContentTests.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent + +import arrow.core.right +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody +import ch.protonmail.android.networkmocks.assets.RawAssets +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bannerSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.unmockkAll +import me.proton.core.auth.domain.usecase.ValidateServerProof +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Before +import org.junit.Test + +/** + * Separate suite to mock decryption when UI testing as we can't possibly rely on links that might expire. + * Will be improved once we intercept the remote content calls via MockWebServer (currently not possible). + */ +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MockedDetailRemoteContentTests : MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara) { + + private val expectedBodyText = "Various img elements" + private val expectedBodyTextLastMessage = "Various img elements (2)" + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @JvmField + @BindValue // GetDecryptedMessageBody needs to be mocked to make sure content is passed as expected to the WebView. + val decryptedMessageBody: GetDecryptedMessageBody = mockk().apply { mockDecryptedBody() } + + @Before + fun reset() { + unmockkAll() + } + + @Test + @TestId("184207", "212679") + fun checkRemoteContentNotBlockedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184207.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_184207.json" + withStatusCode 200 matchWildcards true ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184207.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(true) + } + } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @SmokeTest + @TestId("184206", "212680") + fun checkRemoteContentNotBlockedInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184206.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_184206.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_184206.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184206.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(true) + } + } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + @Test + @TestId("184208", "184209") + fun checkRemoteContentNotBlockedOnMultipleMessagesInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_184209.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_184209.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_184209.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184209.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_184209_2.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + decryptedMessageBody.mockDecryptedBody(PlaceholderMockTwo) + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyTextLastMessage) + hasRemoteImageLoaded(true) + } + } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + + messageHeaderSection { + expanded { collapse() } + } + + decryptedMessageBody.mockDecryptedBody(PlaceholderMockOne) + + messagesCollapsedSection { + scrollToTop() + openMessageAtIndex(0) + } + + messageBodySection { + waitUntilMessageIsShown() + + verify { + messageInWebViewContains(expectedBodyText) + hasRemoteImageLoaded(true) + } + } + + bannerSection { verify { hasBlockedContentBannerNotDisplayed() } } + } + } + + private fun GetDecryptedMessageBody.mockDecryptedBody(assetName: String = PlaceholderMockOne) { + coEvery { this@mockDecryptedBody.invoke(any(), any()) } returns getHtmlMessageBodyContent(assetName).right() + } + + private fun getHtmlMessageBodyContent(assetName: String): DecryptedMessageBody { + val content = requireNotNull(RawAssets.getRawContentForPath(HtmlAssetsPath + assetName)) { + "Unable to retrieve content for file '$assetName'." + } + + return DecryptedMessageBody( + MessageId("html-message-id"), + String(content), + MimeType.Html, + emptyList(), + UserAddressSample.PrimaryAddress + ) + } + + companion object { + + private const val HtmlAssetsPath = "assets/network-mocks/html-assets/" + const val PlaceholderMockOne = "html_remote_content_placeholder.html" + const val PlaceholderMockTwo = "html_remote_content_placeholder_2.html" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt new file mode 100644 index 0000000000..23f446ef56 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/ConversationDetailWebViewTests.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.utils.mocks.WebViewProviderMocks.mockWebViewAvailabilityOnDevice +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailWebViewTests : MockedNetworkTest(), MockedWebViewTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("225746") + fun testWebViewProviderNotPresentShowsWarningMessageInPlaceOfBodyInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_225746.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_225746.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_225746.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_225746.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + mockWebViewAvailabilityOnDevice(isPackagePresent = false) + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { + verify { isShowingMissingWebViewWarning() } + } + } + } + + @Test + @TestId("225746/2", "225748") + fun testWebViewProviderPresentButDisabledShowsWarningMessageInPlaceOfBodyInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_225746.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_225746.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_225746.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_225746.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = false) + + navigator { + navigateTo(Destination.MailDetail()) + } + + conversationDetailRobot { + messageBodySection { + verify { isShowingMissingWebViewWarning() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt new file mode 100644 index 0000000000..fc47133e96 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MessageDetailWebViewTests.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.utils.mocks.WebViewProviderMocks.mockWebViewAvailabilityOnDevice +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailWebViewTests : MockedNetworkTest(), MockedWebViewTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("225746/3") + fun testWebViewProviderNotPresentShowsWarningMessageInPlaceOfBodyInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_225746_3.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_225746_3.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_225746_3.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + mockWebViewAvailabilityOnDevice(isPackagePresent = false) + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { + verify { isShowingMissingWebViewWarning() } + } + } + } + + @Test + @TestId("225746/4", "225748/2") + fun testWebViewProviderPresentButDisabledShowsWarningMessageInPlaceOfBodyInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_225746_3.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_225746_3.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_225746_3.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = false) + + navigator { + navigateTo(Destination.MailDetail()) + } + + messageDetailRobot { + messageBodySection { + verify { isShowingMissingWebViewWarning() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt new file mode 100644 index 0000000000..20f2b2b329 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bodycontent/webview/MockedWebViewTests.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bodycontent.webview + +import android.webkit.WebView +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before + +interface MockedWebViewTests { + + @Before + fun mockWebView() { + mockkStatic(WebView::class) + } + + @After + fun teardown() { + unmockkAll() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt new file mode 100644 index 0000000000..0cdd3b075e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/ConversationDetailMoveToBottomSheetDismissalTests.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.labelas + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.detail.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailMoveToBottomSheetDismissalTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("79353") + fun checkConversationMoveToBottomSheetDismissalWithBackButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79353.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_79353.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_79353.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79353.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + } + } + + // Physical/soft key press is required by this test case. + uiDevice.pressBack() + + conversationDetailRobot { + verify { conversationDetailScreenIsShown() } + + moveToBottomSheetSection { + verify { isHidden() } + } + } + } + + @Test + @TestId("79355") + fun checkConversationMoveToBottomSheetDismissalWithExternalTap() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79355.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_79355.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_79355.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79355.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + } + + // Tap outside the view. + messageHeaderSection { + expandHeader() + } + + verify { conversationDetailScreenIsShown() } + + moveToBottomSheetSection { + verify { isHidden() } + } + } + } + + @Test + @TestId("458329") + fun checkConversationMoveToBottomSheetDismissalWithDoneButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_458329.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_458329.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_458329.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_458329.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + + tapDoneButton() + verify { isHidden() } + } + + verify { conversationDetailScreenIsShown() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt new file mode 100644 index 0000000000..7d96d6b1aa --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/labelas/MessageDetailLabelAsBottomSheetDismissalTests.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.labelas + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.detail.verify +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailLabelAsBottomSheetDismissalTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("79354/2") + fun checkMessageLabelAsBottomSheetDismissalWithBackButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79354.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_79354.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79354.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(messagePosition = 0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bottomBarSection { + openLabelAsBottomSheet() + + verify { labelAsBottomSheetExists() } + } + } + + uiDevice.pressBack() + + messageDetailRobot { + bottomBarSection { + verify { labelAsBottomSheetIsDismissed() } + } + + verify { messageDetailScreenIsShown() } + } + } + + @Test + @TestId("79356/2") + fun checkMessageLabelAsBottomSheetDismissalWithExternalTap() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79356.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_79356.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79356.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(messagePosition = 0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bottomBarSection { + openLabelAsBottomSheet() + + verify { labelAsBottomSheetExists() } + } + + // Tap outside the view. + messageHeaderSection { expandHeader() } + + bottomBarSection { + verify { labelAsBottomSheetIsDismissed() } + } + + verify { messageDetailScreenIsShown() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt new file mode 100644 index 0000000000..f06039a25c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailLabelAsBottomSheetDismissalTests.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.detail.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailLabelAsBottomSheetDismissalTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("79353/2") + fun checkConversationLabelAsBottomSheetDismissalWithBackButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79353.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_79353.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_79353.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79353.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bottomBarSection { + openLabelAsBottomSheet() + verify { labelAsBottomSheetExists() } + } + } + + // Physical/soft key press is required by this test case. + uiDevice.pressBack() + + conversationDetailRobot { + verify { conversationDetailScreenIsShown() } + + bottomBarSection { + verify { labelAsBottomSheetIsDismissed() } + } + } + } + + @Test + @TestId("79355/2") + fun checkConversationLabelAsBottomSheetDismissalWithExternalTap() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79355.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_79355.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_79355.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79355.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + + bottomBarSection { + openLabelAsBottomSheet() + + verify { labelAsBottomSheetExists() } + } + + // Tap outside the view. + messageHeaderSection { + expandHeader() + } + + verify { conversationDetailScreenIsShown() } + + bottomBarSection { + verify { labelAsBottomSheetIsDismissed() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt new file mode 100644 index 0000000000..1d64ba83ae --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/ConversationDetailMoveToBottomSheetMainTests.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationDetailMoveToBottomSheetMainTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("185409") + fun checkMoveToBottomSheetComponentsInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185409.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185409.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { + headerTextIsShown() + doneButtonIsShown() + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt new file mode 100644 index 0000000000..65ba187683 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetActionTests.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.MessageDetailSnackbar +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Archive +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Inbox +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class DetailMoveToBottomSheetActionTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val firstCustomFolder = MoveToBottomSheetFolderEntry( + index = 0, name = "Test Folder", iconTint = Tint.WithColor.Carrot + ) + private val secondCustomFolder = MoveToBottomSheetFolderEntry( + index = 1, name = "Child Test Folder", iconTint = Tint.WithColor.Fern + ) + + private val expectedMailboxItem = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting3")), + subject = "Move this somewhere else", + date = "Mar 28, 2023" + ) + + @Test + @TestId("185418") + fun checkMoveToBottomSheetSystemToSystemFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185418.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185418.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185418_2.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185418.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185418.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Archive) + } + + mailboxRobot { + emptyListSection { verify { isShown() } } + } + + moveMessageToFolder( + startingFolder = Inbox, + destinationFolder = Archive + ) + } + + @Test + @TestId("185419") + fun checkMoveToBottomSheetCustomToCustomFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false, + useDefaultMailReadResponses = true + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185419.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_185419.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=testid&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=childid&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419_2.json" + withStatusCode 200, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185419.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + moveMessageToFolder( + startingFolder = firstCustomFolder, + destinationFolder = secondCustomFolder + ) + } + + @Test + @TestId("185419/2", "185421") + fun checkMoveToBottomSheetCustomToSystemFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false, + useDefaultMailReadResponses = true + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185419.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_185419.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=testid&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419_3.json" + withStatusCode 200, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419_3.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185419.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + moveMessageToFolder( + startingFolder = firstCustomFolder, + destinationFolder = Archive + ) + } + + @Test + @TestId("185419/3", "185423") + fun checkMoveToBottomSheetSystemToCustomFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false, + useDefaultMailReadResponses = true + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185419.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_185419.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=6&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419_3.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=childid&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185419_2.json" + withStatusCode 200, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185419_2.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185419_3.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + moveMessageToFolder( + startingFolder = Archive, + destinationFolder = secondCustomFolder + ) + } + + private fun moveMessageToFolder( + index: Int = 0, + startingFolder: MoveToBottomSheetFolderEntry, + destinationFolder: MoveToBottomSheetFolderEntry + ) { + val snackbar = MessageDetailSnackbar.ConversationMovedToFolder(destinationFolder.name) + + menuRobot { + openSidebarMenu() + openFolderWithName(startingFolder.name) + } + + mailboxRobot { + listSection { clickMessageByPosition(index) } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + selectFolderWithName(destinationFolder.name) + tapDoneButton() + + verify { isHidden() } + } + + snackbarSection { + verify { isDisplaying(snackbar) } + } + } + + menuRobot { + openSidebarMenu() + openFolderWithName(destinationFolder.name) + } + + mailboxRobot { + listSection { verify { listItemsAreShown(expectedMailboxItem) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt new file mode 100644 index 0000000000..2ad38e64ea --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetLabelsTests.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.MailLabelEntry +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry.SystemFolders.Trash +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class DetailMoveToBottomSheetLabelsTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val startMailboxItem = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")), + labels = listOf(MailLabelEntry(index = 0, name = "Test Label")), + subject = "Example test", + date = "Mar 6, 2023" + ) + + private val finalMailboxItem = startMailboxItem.copy(labels = emptyList()) + + @Test + @TestId("185425") + fun checkMoveToBottomSheetMoveToTrashFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultLabels = false, + useDefaultCustomFolders = false, + useDefaultMailReadResponses = true + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185425.json" + withStatusCode 200, + get("/core/v4/labels?Type=1") + respondWith "/core/v4/labels/labels-type1_185425.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185425.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185425_2.json" + withStatusCode 200, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185425.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185425.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(startMailboxItem) } + clickMessageByPosition(0) + } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + selectFolderWithName(Trash.name) + tapDoneButton() + } + } + + menuRobot { + openSidebarMenu() + openTrash() + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(finalMailboxItem) } + } + } + } + + @Test + @TestId("185425/2", "185426") + fun checkMoveToBottomSheetMoveToSpamFolder() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultLabels = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185425.json" + withStatusCode 200, + get("/core/v4/labels?Type=1") + respondWith "/core/v4/labels/labels-type1_185425.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185425.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_185425_3.json" + withStatusCode 200, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_185425.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185425.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(startMailboxItem) } + clickMessageByPosition(0) + } + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + selectFolderWithName(Trash.name) + tapDoneButton() + } + } + + menuRobot { + openSidebarMenu() + openSpam() + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(finalMailboxItem) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt new file mode 100644 index 0000000000..f80f654a45 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/DetailMoveToBottomSheetMainTests.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class DetailMoveToBottomSheetMainTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val firstCustomFolder = MoveToBottomSheetFolderEntry( + index = 0, name = "Test Folder", iconTint = Tint.WithColor.Carrot + ) + private val secondCustomFolder = MoveToBottomSheetFolderEntry( + index = 1, name = "Child Test Folder", iconTint = Tint.WithColor.Fern + ) + private val systemFolders = arrayOf( + MoveToBottomSheetFolderEntry.SystemFolders.Inbox, + MoveToBottomSheetFolderEntry.SystemFolders.Archive, + MoveToBottomSheetFolderEntry.SystemFolders.Spam, + MoveToBottomSheetFolderEntry.SystemFolders.Trash + ) + + @Test + @TestId("185411") + fun checkMoveToBottomSheetComponentsWithNoCustomFolders() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185411.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185411.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { hasFolders(*systemFolders) } + } + } + } + + @Test + @TestId("185412") + fun checkMoveToBottomSheetComponentsWithCustomFolders() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false, + useDefaultMailReadResponses = true + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185412.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_185412.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185412.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedFolders = arrayOf(firstCustomFolder, secondCustomFolder).combineWithSystemFolders() + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { hasFolders(*expectedFolders) } + } + } + } + + @Test + @TestId("185414") + fun checkMoveToBottomSheetComponentsSelection() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185414.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185414.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedSelectedFolders = systemFolders.map { + if (it == MoveToBottomSheetFolderEntry.SystemFolders.Trash) it.copy(isSelected = true) else it + }.toTypedArray() + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + selectFolderAtPosition(MoveToBottomSheetFolderEntry.SystemFolders.Trash.index) + verify { hasFolders(*expectedSelectedFolders) } + + tapDoneButton() + verify { isHidden() } + } + } + } + + @Test + @TestId("185415") + fun checkMoveToBottomSheetSelectionIsGoneAfterDismissal() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultCustomFolders = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185415.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder_empty.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations/*") + respondWith "/mail/v4/conversations/conversation-id/conversation-id_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185415.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + val expectedSelectedFolders = systemFolders.map { + if (it == MoveToBottomSheetFolderEntry.SystemFolders.Trash) it.copy(isSelected = true) else it + }.toTypedArray() + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + conversationDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + selectFolderAtPosition(MoveToBottomSheetFolderEntry.SystemFolders.Trash.index) + verify { hasFolders(*expectedSelectedFolders) } + dismiss() + } + + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { hasFolders(*systemFolders) } + } + } + } + + private fun Array.combineWithSystemFolders(): Array { + return systemFolders.map { + it.copy(index = size + it.index) + }.toTypedArray() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt new file mode 100644 index 0000000000..185102c188 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetDismissalTests.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.detail.verify +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailMoveToBottomSheetDismissalTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("79354") + fun checkMessageMoveToBottomSheetDismissalWithBackButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79354.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_79354.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79354.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(messagePosition = 0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + } + } + + // Physical/soft key press is required by this test case. + uiDevice.pressBack() + + messageDetailRobot { + moveToBottomSheetSection { + verify { isHidden() } + } + + verify { messageDetailScreenIsShown() } + } + } + + @Test + @TestId("79356") + fun checkMessageMoveToBottomSheetDismissalWithExternalTap() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79356.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_79356.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_79356.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(messagePosition = 0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + } + + // Tap outside the view. + messageHeaderSection { expandHeader() } + + moveToBottomSheetSection { + verify { isHidden() } + } + + verify { messageDetailScreenIsShown() } + } + } + + @Test + @TestId("485329") + fun checkMessageMoveToBottomSheetDismissalWithDoneButton() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_458330.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_458330.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_458330.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(messagePosition = 0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { isShown() } + + tapDoneButton() + verify { isHidden() } + } + + verify { messageDetailScreenIsShown() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt new file mode 100644 index 0000000000..d0029cb565 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/detail/bottomsheet/moveto/MessageDetailMoveToBottomSheetMainTests.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.detail.bottomsheet.moveto + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.moveToBottomSheetSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageDetailMoveToBottomSheetMainTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("185410") + fun checkMoveToBottomSheetComponentsInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_185410.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_185410.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_185410.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.MailDetail(0)) + } + + messageDetailRobot { + messageBodySection { waitUntilMessageIsShown() } + bottomBarSection { openMoveToBottomSheet() } + + moveToBottomSheetSection { + verify { + headerTextIsShown() + doneButtonIsShown() + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt new file mode 100644 index 0000000000..781eedc95b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/DraftsMailboxTests.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class DraftsMailboxTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("80395") + fun checkParticipantNameInDraftFoldersWhenNotSpecified() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80395.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80395.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedMailboxEntry = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.Draft, + participants = listOf(ParticipantEntry.NoRecipient), + subject = "Test subject", + date = "Apr 3, 2023" + ) + + navigator { + navigateTo(Destination.Drafts) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(expectedMailboxEntry) } + } + } + } + + @Test + @TestId("80396") + fun checkParticipantNameInDraftFoldersWhenSpecifiedAndIsAContact() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultContacts = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80396.json" + withStatusCode 200, + get("/contacts/v4/contacts") + respondWith "/contacts/v4/contacts/contacts_80396.json" + withStatusCode 200 ignoreQueryParams true, + get("/contacts/v4/contacts/emails") + respondWith "/contacts/v4/contacts/emails/contacts-emails_80396.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80396.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedMailboxEntry = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.Draft, + participants = listOf(ParticipantEntry.WithParticipant("UI Tests Contact 1")), + subject = "Test subject", + date = "Apr 3, 2023" + ) + + navigator { + navigateTo(Destination.Drafts) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(expectedMailboxEntry) } + } + } + } + + @Test + @TestId("80397") + fun checkParticipantNameInDraftFoldersWhenSpecifiedAndIsNotAContact() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultMailSettings = false, + useDefaultContacts = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_80397.json" + withStatusCode 200, + get("/contacts/v4/contacts") + respondWith "/contacts/v4/contacts/contacts_80397.json" + withStatusCode 200 ignoreQueryParams true, + get("/contacts/v4/contacts/emails") + respondWith "/contacts/v4/contacts/emails/contacts-emails_80397.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_80397.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + val expectedMailboxEntry = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.Draft, + participants = listOf(ParticipantEntry.WithParticipant("Mobile Apps UI Testing 2")), + subject = "Test subject", + date = "Apr 3, 2023" + ) + + navigator { + navigateTo(Destination.Drafts) + } + + mailboxRobot { + listSection { + verify { listItemsAreShown(expectedMailboxEntry) } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt new file mode 100644 index 0000000000..68b629d53f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsErrorTests.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.simulateNoNetwork +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.TestingNotes +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.model.snackbar.ComposerSnackbar +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class OpenExistingDraftsErrorTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + OpenExistingDraftsTest { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedToChip = RecipientChipEntry( + index = 0, + text = "aa@bb.cc", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + + private val expectedSubject = "Test subject" + private val expectedMessageBody = "Some text" + + @Test + @TestId("212667") + fun openingDraftInOfflineModeWithNoLocalCacheShowsSnackbarWarning() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212667.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + simulateNoNetwork true matchWildcards true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } } + verifyEmptyFields() + } + } + + @Test + @SmokeTest + @TestId("212668") + fun openingDraftWhenBeReturnsErrorWithNoLocalCacheShowsSnackbarWarning() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212668.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyEmptyFields() + snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } } + } + } + + @Test + @TestId("212669") + fun openingDraftWithDecryptionErrorShowsOutOfSyncWarning() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212669.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212669.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyEmptyFields() + snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } } + } + } + + @Test + @TestingNotes("Add snackbar check once it's implemented (see MAILANDR-862)") + @TestId("212670") + fun openingDraftInOfflineModeWithLocalCacheShowsCachedData() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212670.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212670.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212670.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + simulateNoNetwork true matchWildcards true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + verifyLoadedCachedData() + } + + @Test + @SmokeTest + @TestingNotes("Add snackbar check once it's implemented (see MAILANDR-862)") + @TestId("212673") + fun openingDraftOnBeErrorWithLocalCacheShowsCachedData() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212673.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212673.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212673.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + verifyLoadedCachedData() + } + + @Test + @TestId("212674") + fun openingDraftOnDecryptionErrorWithLocalCacheShowsEmptyFields() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212674.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212674.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212674_2.json" + withStatusCode 200 matchWildcards true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyPrefilledFields( + toRecipientChip = expectedToChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + + topAppBarSection { tapCloseButton() } + } + + mailboxRobot { listSection { clickMessageByPosition(0) } } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + verifyEmptyFields() + snackbarSection { verify { isDisplaying(ComposerSnackbar.DraftOutOfSync) } } + } + } + + private fun verifyLoadedCachedData() { + composerRobot { + verifyPrefilledFields( + toRecipientChip = expectedToChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + + topAppBarSection { tapCloseButton() } + } + + mailboxRobot { listSection { clickMessageByPosition(0) } } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + + verifyPrefilledFields( + toRecipientChip = expectedToChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt new file mode 100644 index 0000000000..c1394c3d1e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsHappyPathTests.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.drafts + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import ch.protonmail.android.uitest.robot.composer.section.senderSection +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.topAppBarSection +import ch.protonmail.android.uitest.robot.composer.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class OpenExistingDraftsHappyPathTests : + MockedNetworkTest(loginType = LoginTestUserTypes.Paid.FancyCapybara), + OpenExistingDraftsTest { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val expectedToChip = RecipientChipEntry( + index = 0, + text = "aa@bb.cc", + hasDeleteIcon = true, + state = RecipientChipValidationState.Valid + ) + + private val expectedCcChip = RecipientChipEntry( + index = 0, + text = "dd@ee.ff", + state = RecipientChipValidationState.Valid + ) + + private val expectedBccChip = RecipientChipEntry( + index = 0, + text = "gg@hh.ii", + state = RecipientChipValidationState.Valid + ) + + private val expectedSubject = "Test subject" + private val expectedSubjectPlaceholder = "(No Subject)" + private val expectedMessageBody = "Some text" + private val expectedAliasAddress = "shortcapybara@pm.me.proton.black" + + @Test + @TestId("212662", "212664") + fun openDraftWithPrefilledToRecipientAndOtherFields() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212662.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212662.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + toRecipientSection { verify { hasRecipientChips(expectedToChip) } } + ccRecipientSection { verify { isHidden() } } + bccRecipientSection { verify { isHidden() } } + toRecipientSection { expandCcAndBccFields() } + ccRecipientSection { verify { isEmptyField() } } + bccRecipientSection { verify { isEmptyField() } } + subjectSection { verify { hasSubject(expectedSubject) } } + messageBodySection { verify { hasText(expectedMessageBody) } } + } + } + + @Test + @SmokeTest + @TestId("212663") + fun openDraftWithAllPrefilledFields() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212663.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212663.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyPrefilledFields( + toRecipientChip = expectedToChip, + ccRecipientChip = expectedCcChip, + bccRecipientChip = expectedBccChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + } + } + + @Test + @SmokeTest + @TestId("212663/2") + fun openDraftWithAllPrefilledFieldsInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=8&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_212663.json" + withStatusCode 200 withPriority MockPriority.Highest, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212663.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyPrefilledFields( + toRecipientChip = expectedToChip, + ccRecipientChip = expectedCcChip, + bccRecipientChip = expectedBccChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + } + } + + @Test + @TestId("212665") + fun openDraftWithoutSubjectAndMessageBody() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212665.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212665.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyPrefilledFields(toRecipientChip = expectedToChip, subject = expectedSubjectPlaceholder) + } + } + + @Test + @TestId("212666") + fun openingDraftsAlwaysFetchesRemoteContentFirst() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212666.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212666.json" + withStatusCode 200 matchWildcards true serveOnce true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212666_2.json" + withStatusCode 200 matchWildcards true + ) + } + + val expectedUpdatedToChip = expectedToChip.copy(text = "aa@bb2.cc") + val expectedUpdatedSubject = "Test subject 2" + val expectedUpdatedMessageBody = "Some text 2" + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + verifyPrefilledFields( + toRecipientChip = expectedToChip, + subject = expectedSubject, + messageBody = expectedMessageBody + ) + + topAppBarSection { tapCloseButton() } + } + + mailboxRobot { + listSection { clickMessageByPosition(0) } + } + + composerRobot { + fullscreenLoaderSection { waitUntilGone() } + + verifyPrefilledFields( + toRecipientChip = expectedUpdatedToChip, + subject = expectedUpdatedSubject, + messageBody = expectedUpdatedMessageBody + ) + } + } + + @Test + @TestId("212675") + fun openingDraftPreservesSenderAddress() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_212675.json" + withStatusCode 200 ignoreQueryParams true, + get("/mail/v4/messages/*") + respondWith "/mail/v4/messages/message-id/message-id_212675.json" + withStatusCode 200 matchWildcards true serveOnce true + ) + } + + navigator { + navigateTo(Destination.EditDraft()) + } + + composerRobot { + senderSection { verify { hasValue(expectedAliasAddress) } } + toRecipientSection { verify { hasRecipientChips(expectedToChip) } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt new file mode 100644 index 0000000000..af3fad2ed2 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/drafts/OpenExistingDraftsTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.drafts + +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.section.messageBodySection +import ch.protonmail.android.uitest.robot.composer.section.recipients.bccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.ccRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.toRecipientSection +import ch.protonmail.android.uitest.robot.composer.section.recipients.verify +import ch.protonmail.android.uitest.robot.composer.section.subjectSection +import ch.protonmail.android.uitest.robot.composer.section.verify + +internal interface OpenExistingDraftsTest { + + fun ComposerRobot.verifyPrefilledFields( + toRecipientChip: RecipientChipEntry, + ccRecipientChip: RecipientChipEntry? = null, + bccRecipientChip: RecipientChipEntry? = null, + subject: String, + messageBody: String? = null + ) { + toRecipientSection { + verify { hasRecipientChips(toRecipientChip) } + } + + if (ccRecipientChip != null && bccRecipientChip != null) { + toRecipientSection { verify { chevronNotVisible() } } + ccRecipientSection { verify { hasRecipientChips(ccRecipientChip) } } + bccRecipientSection { verify { hasRecipientChips(bccRecipientChip) } } + } else { + toRecipientSection { expandCcAndBccFields() } + ccRecipientSection { verify { isEmptyField() } } + bccRecipientSection { verify { isEmptyField() } } + } + + subjectSection { verify { hasSubject(subject) } } + messageBodySection { verify { messageBody?.let { hasText(it) } ?: hasPlaceholderText() } } + } + + fun ComposerRobot.verifyEmptyFields() { + toRecipientSection { + verify { isEmptyField() } + expandCcAndBccFields() + } + ccRecipientSection { verify { isEmptyField() } } + bccRecipientSection { verify { isEmptyField() } } + subjectSection { verify { hasEmptySubject() } } + messageBodySection { verify { hasPlaceholderText() } } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt new file mode 100644 index 0000000000..5cc4c30b6f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/ConversationModeAppendItemsTests.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.append + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Ignore +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@Ignore("To be enabled again when MAILANDR-1162 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class ConversationModeAppendItemsTests : MockedNetworkTest(), MailboxAppendItemsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + override val lastExpectedMailboxItem = MailboxListItemEntry( + index = 101, + avatarInitial = AvatarInitial.WithText("P"), + participants = listOf(ParticipantEntry.WithParticipant("Proton", isProton = true)), + subject = "Last Element!", + date = "May 7, 2023" + ) + + @Test + @TestId("189113") + @Suppress("MaxLineLength") + fun checkAppendErrorAndRetryInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_189113_1.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683964808&EndID=-ca1Hsn5gJ5pVXKT683Jks9DF_HMYnJ320IAdwRamIM8Y-qmce6sHmX9ybG692_KPk89lEuTp5OU0iAFzwF2zA%3D%3D") + respondWith "/mail/v4/conversations/conversations_189113_2.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 withPriority MockPriority.Highest withNetworkDelay 2000 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D") + respondWith "/mail/v4/conversations/conversations_189113_3.json" + withStatusCode 200 withNetworkDelay 2000 + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyAppendAdditionalItemsErrorAndRetry() + } + + @Test + @TestId("189113/2", "189158") + @Suppress("MaxLineLength") + fun checkAppendItemsInConversationMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_189113_1.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683964808&EndID=-ca1Hsn5gJ5pVXKT683Jks9DF_HMYnJ320IAdwRamIM8Y-qmce6sHmX9ybG692_KPk89lEuTp5OU0iAFzwF2zA%3D%3D") + respondWith "/mail/v4/conversations/conversations_189113_2.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/conversations?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1683532886&EndID=yFFVROGaGAfA0O4rOchW_1oF_-Giys_QfSaRS69zTeWOuyQmwx_SESSDZlVp67N76pBde92SyQ-cMDlA_71T5w%3D%3D") + respondWith "/mail/v4/conversations/conversations_189113_3.json" + withStatusCode 200 withNetworkDelay 2000 + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyAppendAdditionalItems() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt new file mode 100644 index 0000000000..e867c47ccb --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MailboxAppendItemsTests.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.append + +import ch.protonmail.android.uitest.e2e.mailbox.errors.append.ScrollThreshold.FirstScrollThreshold +import ch.protonmail.android.uitest.e2e.mailbox.errors.append.ScrollThreshold.SecondScrollThreshold +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.appendErrorSection +import ch.protonmail.android.uitest.robot.mailbox.section.appendLoadingSection +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify + +internal interface MailboxAppendItemsTests { + + val lastExpectedMailboxItem: MailboxListItemEntry + + fun verifyAppendAdditionalItems() = verifyAppendItemsLoading(expectError = false) + + fun verifyAppendAdditionalItemsErrorAndRetry() = verifyAppendItemsLoading(expectError = true) + + private fun verifyAppendItemsLoading(expectError: Boolean) { + mailboxRobot { + listSection { + scrollToItemAtIndex(FirstScrollThreshold) + scrollToItemAtIndex(SecondScrollThreshold) + } + + appendLoadingSection { verify { isShown() } } + + if (expectError) { + appendErrorSection { + verify { isShown() } + tapRetryButton() + verify { isHidden() } + } + } + + appendLoadingSection { verify { isHidden() } } + + listSection { + verify { listItemsAreShown(lastExpectedMailboxItem) } + } + } + } +} + +private object ScrollThreshold { + + const val FirstScrollThreshold = 75 + const val SecondScrollThreshold = 99 +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt new file mode 100644 index 0000000000..7f36c72f08 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/append/MessageModeAppendItemsTests.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.append + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Ignore +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +@Ignore("To be enabled again when MAILANDR-1162 is addressed.") +@UninstallModules(ServerProofModule::class) +internal class MessageModeAppendItemsTests : MockedNetworkTest(), MailboxAppendItemsTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + override val lastExpectedMailboxItem = MailboxListItemEntry( + index = 101, + avatarInitial = AvatarInitial.WithText("P"), + participants = listOf(ParticipantEntry.WithParticipant("Proton", isProton = true)), + subject = "Last Element!", + date = "Mar 6, 2023" + ) + + @Test + @TestId("189114") + @Suppress("MaxLineLength") + fun checkAppendErrorAndRetryInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_189114_1.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1687181980&EndID=Ipolvuzgp9N-3XwngHmiQZ9fDZV3CUSv65Pi3SjL75I_-mhS4sxdT2qNo5-GEoLuFuzInClxbq3tKM9MydlQzQ%3D%3D") + respondWith "/mail/v4/messages/messages_189114_2.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 withPriority MockPriority.Highest withNetworkDelay 2000 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D") + respondWith "/mail/v4/messages/messages_189114_3.json" + withStatusCode 200 withNetworkDelay 2000 + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyAppendAdditionalItemsErrorAndRetry() + } + + @Test + @TestId("189114/2", "189159") + @Suppress("MaxLineLength") + fun checkAppendItemsInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages?Page=0&PageSize=75&Limit=75&LabelID=0&Sort=Time&Desc=1") + respondWith "/mail/v4/messages/messages_189114_1.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1687181980&EndID=Ipolvuzgp9N-3XwngHmiQZ9fDZV3CUSv65Pi3SjL75I_-mhS4sxdT2qNo5-GEoLuFuzInClxbq3tKM9MydlQzQ%3D%3D") + respondWith "/mail/v4/messages/messages_189114_2.json" + withStatusCode 200 serveOnce true, + get("/mail/v4/messages?Page=0&PageSize=25&Limit=25&LabelID=0&Sort=Time&Desc=1&End=1678107386&EndID=Q0bXXG7rlW34PI8sKXbIliVZ2ybuoIefe933RlbTZYrjpl1nsWG7FKTAW7s4nnskarFkbvpPVOESe0omarcHsQ%3D%3D") + respondWith "/mail/v4/messages/messages_189114_3.json" + withStatusCode 200 withNetworkDelay 2000 + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyAppendAdditionalItems() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt new file mode 100644 index 0000000000..b14e6c6b87 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/ConversationModeMailboxPullToRefreshErrorTests.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class ConversationModeMailboxPullToRefreshErrorTests : + MockedNetworkTest(), MailboxPullToRefreshErrorTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("188892") + fun checkConversationsLoadingErrorToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToError() + } + + @Test + @TestId("188893") + fun checkConversationsLoadingContentToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyContentToError() + } + + @Test + @SmokeTest + @TestId("188894") + fun checkConversationsLoadingErrorToContent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToContent() + } + + @Test + @TestId("188895") + fun checkConversationsLoadingEmptyToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyEmptyToError() + } + + @Test + @TestId("188896") + fun checkConversationsLoadingErrorToEmpty() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToEmpty() + } + + @Test + @SmokeTest + @TestId("188897") + fun checkConversationsLoadingEmptyToContent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_empty.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyEmptyToContent() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt new file mode 100644 index 0000000000..22c11b8314 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MailboxPullToRefreshErrorTests.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh + +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.common.section.snackbarSection +import ch.protonmail.android.uitest.robot.common.section.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.model.snackbar.MailboxSnackbar +import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection +import ch.protonmail.android.uitest.robot.mailbox.section.fullScreenErrorSection +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify + +internal interface MailboxPullToRefreshErrorTests { + + private val baseItem: MailboxListItemEntry + get() = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("M"), + participants = listOf(ParticipantEntry.WithParticipant("mobileappsuitesting2")), + subject = "Test message", + date = "Mar 6, 2023" + ) + + fun verifyEmptyToContent() { + mailboxRobot { + emptyListSection { + verify { isShown() } + + pullDownToRefresh() + } + + listSection { + verify { listItemsAreShown(baseItem) } + } + } + } + + fun verifyErrorToEmpty() { + mailboxRobot { + fullScreenErrorSection { + verify { isShown() } + + pullDownToRefresh() + } + + emptyListSection { + verify { isShown() } + } + } + } + + fun verifyEmptyToError() { + mailboxRobot { + emptyListSection { + verify { isShown() } + + pullDownToRefresh() + } + + fullScreenErrorSection { + verify { isShown() } + } + } + } + + fun verifyErrorToContent() { + mailboxRobot { + fullScreenErrorSection { + verify { isShown() } + + pullDownToRefresh() + } + + listSection { + verify { listItemsAreShown(baseItem) } + } + } + } + + fun verifyContentToError() { + mailboxRobot { + listSection { + verify { listItemsAreShown(baseItem) } + + pullDownToRefresh() + } + + snackbarSection { + verify { isDisplaying(MailboxSnackbar.FailedToLoadNewItems) } + } + + listSection { + verify { listItemsAreShown(baseItem) } + } + } + } + + fun verifyErrorToError() { + mailboxRobot { + fullScreenErrorSection { + verify { isShown() } + pullDownToRefresh() + verify { isShown() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt new file mode 100644 index 0000000000..8772aee429 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/errors/pulltorefresh/MessageModeMailboxPullToRefreshErrorTests.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.errors.pulltorefresh + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.serveOnce +import ch.protonmail.android.networkmocks.mockwebserver.requests.withNetworkDelay +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class MessageModeMailboxPullToRefreshErrorTests : + MockedNetworkTest(), MailboxPullToRefreshErrorTests { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("188899") + fun checkMessagesLoadingErrorToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToError() + } + + @Test + @TestId("188900") + fun checkMessagesLoadingContentToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyContentToError() + } + + @Test + @SmokeTest + @TestId("188901") + fun checkMessagesLoadingErrorToContent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToContent() + } + + @Test + @TestId("188902") + fun checkMessagesLoadingEmptyToError() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyEmptyToError() + } + + @Test + @TestId("188903") + fun checkMessagesLoadingErrorToEmpty() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/global/errors/error_mock.json" + withStatusCode 503 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyErrorToEmpty() + } + + @Test + @SmokeTest + @TestId("188904") + fun checkMessagesLoadingEmptyToContent() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true serveOnce true, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true withNetworkDelay 2000 serveOnce true + ) + } + + navigator { + navigateTo(Destination.Inbox) + } + + verifyEmptyToContent() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt new file mode 100644 index 0000000000..24bc495fc5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarActionsTests.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.selection + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.bottombar.BottomBarActionEntry +import ch.protonmail.android.uitest.robot.bottombar.bottomBarSection +import ch.protonmail.android.uitest.robot.bottombar.verify +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class SelectionModeBottomBarActionsTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("216624") + fun testBottomBarInboxActionsOnUnreadMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_216624.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnUnreadItem) + } + } + + @Test + @TestId("216624/2") + fun testBottomBarInboxActionsOnReadMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_216624_2.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnReadItem) + } + } + + @Test + @TestId("216625") + fun testBottomBarInboxActionsOnUnreadTrashedMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_216625.json" + withStatusCode 200 + ) + } + + navigator { navigateTo(Destination.Trash) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnTrashedUnreadItem) + } + } + + @Test + @TestId("216625/2") + fun testBottomBarInboxActionsOnReadTrashedMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=3&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_216625_2.json" + withStatusCode 200 + ) + } + + navigator { navigateTo(Destination.Trash) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnTrashedReadItem) + } + } + + @Test + @TestId("216625/3") + fun testBottomBarInboxActionsOnUnreadSpamMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_216625_3.json" + withStatusCode 200 + ) + } + + navigator { navigateTo(Destination.Spam) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnSpamUnreadItem) + } + } + + @Test + @TestId("216625/4") + fun testBottomBarInboxActionsOnReadSpamMessage() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations?Page=0&PageSize=75&Limit=75&LabelID=4&Sort=Time&Desc=1") + respondWith "/mail/v4/conversations/conversations_216625_4.json" + withStatusCode 200 + ) + } + + navigator { navigateTo(Destination.Spam) } + + mailboxRobot { + clickAndMatchSelectionModeBottomBarActions(BottomBarActionEntry.Defaults.actionsOnSpamReadItem) + } + } +} + +private fun MailboxRobot.clickAndMatchSelectionModeBottomBarActions(expectedActions: Array) { + listSection { selectItemsAt(0) } + + bottomBarSection { + verify { hasActions(*expectedActions) } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt new file mode 100644 index 0000000000..272936409a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeBottomBarTests.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.selection + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.bottombar.bottomBarSection +import ch.protonmail.android.uitest.robot.bottombar.verify +import ch.protonmail.android.uitest.robot.helpers.deviceRobot +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class SelectionModeBottomBarTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("216621") + fun testBottomBarDisplayed() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_216621.json" + withStatusCode 200 ignoreQueryParams true, + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { selectItemsAt(0) } + bottomBarSection { verify { isShown() } } + } + } + + @Test + @TestId("216621/2") + fun testBottomBarDisplayedInMessageMode() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_messages.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_216621.json" + withStatusCode 200 ignoreQueryParams true, + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { selectItemsAt(0) } + bottomBarSection { verify { isShown() } } + } + } + + @Test + @TestId("216621/2, 216622") + fun testBottomBarDismissalWhenBackButtonIsPressed() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_216621.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { selectItemsAt(0) } + bottomBarSection { + verify { isShown() } + } + + deviceRobot { pressBack() } + + bottomBarSection { + verify { isNotShown() } + } + + verify { isShown() } + } + } + + @Test + @TestId("216621/3, 216623") + fun testBottomBarDismissalWhenSelectionModeIsExited() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_216621.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + navigator { navigateTo(Destination.Inbox) } + + mailboxRobot { + listSection { selectItemsAt(0) } + bottomBarSection { + verify { isShown() } + } + + listSection { unselectItemsAtPosition(0) } + bottomBarSection { + verify { isNotShown() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt new file mode 100644 index 0000000000..344bb147ad --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/mailbox/selection/SelectionModeMainTests.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.mailbox.selection + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.mailbox.MailboxType +import ch.protonmail.android.uitest.robot.bottombar.bottomBarSection +import ch.protonmail.android.uitest.robot.bottombar.verify +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class SelectionModeMainTests : MockedNetworkTest( + loginType = LoginTestUserTypes.Paid.FancyCapybara +) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Before + fun prepareTests() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher(useDefaultMailSettings = false) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_placeholder_conversation.json" + withStatusCode 200, + get("/mail/v4/conversations") + respondWith "/mail/v4/conversations/conversations_215427.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + + navigator { navigateTo(Destination.Inbox) } + } + + @Test + @TestId("254596") + fun backButtonDismissesSelectionMode() { + val selectedItemPosition = 0 + + mailboxRobot { + listSection { + selectItemsAt(selectedItemPosition) + verify { selectedItemAtPosition(selectedItemPosition) } + } + + bottomBarSection { verify { isShown() } } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 1) } + + tapExitSelectionMode() + verify { isMailbox(MailboxType.Inbox) } + } + + bottomBarSection { verify { isNotShown() } } + } + } + + @Test + @TestId("215426") + fun testItemIsSelectedWithLongPress() { + val selectedItemPosition = 0 + + mailboxRobot { + listSection { + longPressItemAtPosition(selectedItemPosition) + verify { selectedItemAtPosition(selectedItemPosition) } + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 1) } + } + + bottomBarSection { verify { isShown() } } + } + } + + @Test + @TestId("215427", "215428", "215430") + fun testMultipleSelectedItems() { + val selectedItemPosition = 0 + val secondSelectedItemPosition = 1 + val unselectedItemPosition = 2 + val secondUnselectedItemPosition = 3 + + mailboxRobot { + listSection { + selectItemsAt(selectedItemPosition, secondSelectedItemPosition) + + verify { + selectedItemAtPosition(selectedItemPosition) + selectedItemAtPosition(secondSelectedItemPosition) + unSelectedItemAtPosition(unselectedItemPosition) + unSelectedItemAtPosition(secondUnselectedItemPosition) + } + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 2) } + } + + bottomBarSection { verify { isShown() } } + } + } + + @Test + @TestId("215431") + fun testSelectionCountIncreases() { + val selectedItemPosition = 0 + val secondSelectedItemPosition = 1 + + mailboxRobot { + listSection { + selectItemsAt(selectedItemPosition) + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 1) } + } + + bottomBarSection { verify { isShown() } } + + listSection { + selectItemsAt(secondSelectedItemPosition) + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 2) } + } + + bottomBarSection { verify { isShown() } } + } + } + + @Test + @TestId("215429", "215432", "215433") + fun testSelectionCountDecreasesAndExitSelectionMode() { + val selectedItemPosition = 0 + val secondSelectedItemPosition = 1 + + mailboxRobot { + listSection { + selectItemsAt(selectedItemPosition, secondSelectedItemPosition) + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 2) } + } + + bottomBarSection { verify { isShown() } } + + listSection { + unselectItemsAtPosition(secondSelectedItemPosition) + verify { unSelectedItemAtPosition(secondSelectedItemPosition) } + } + + topAppBarSection { + verify { isInSelectionMode(numSelected = 1) } + } + + listSection { + unselectItemsAtPosition(selectedItemPosition) + verify { unSelectedItemAtPosition(selectedItemPosition) } + } + + topAppBarSection { + verify { isMailbox(MailboxType.Inbox) } + } + + bottomBarSection { verify { isNotShown() } } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt new file mode 100644 index 0000000000..edc96b0655 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarMenuFoldersTests.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.menu + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.models.folders.SidebarCustomItemEntry +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.robot.menu.menuRobot +import ch.protonmail.android.uitest.robot.menu.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class SidebarMenuFoldersTests : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("68718") + fun checkShortHexAndStandardColorFolderAreDisplayedInSidebarMenu() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultCustomFolders = false, + useDefaultMailSettings = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_68718.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_68718.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + val expectedFolders = arrayOf( + SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.WithColor.Bridge), + SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.WithColor.PurpleBase) + ) + + navigator { + navigateTo(Destination.Inbox) + } + + menuRobot { + openSidebarMenu() + + verify { customFoldersAreDisplayed(*expectedFolders) } + } + } + + @Test + @TestId("79096") + fun checkFoldersColorWhenSettingIsOffWithNoParentInheritingInSidebarMenu() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultCustomFolders = false, + useDefaultMailSettings = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79096.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_79096.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + val expectedFolders = arrayOf( + SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.NoColor), + SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.NoColor) + ) + + navigator { + navigateTo(Destination.Inbox) + } + + menuRobot { + openSidebarMenu() + verify { customFoldersAreDisplayed(*expectedFolders) } + } + } + + @Test + @TestId("79097") + fun checkFoldersColorWhenSettingIsOffWithParentInheritingInSidebarMenu() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher( + useDefaultCustomFolders = false, + useDefaultMailSettings = false + ) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_79097.json" + withStatusCode 200, + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_79097.json" + withStatusCode 200, + get("/mail/v4/messages") + respondWith "/mail/v4/messages/messages_empty.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + val expectedFolders = arrayOf( + SidebarCustomItemEntry(index = 0, name = "Shorthand Hex Folder", iconTint = Tint.NoColor), + SidebarCustomItemEntry(index = 1, name = "Standard Folder", iconTint = Tint.NoColor) + ) + + navigator { + navigateTo(Destination.Inbox) + } + + menuRobot { + openSidebarMenu() + verify { customFoldersAreDisplayed(*expectedFolders) } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt new file mode 100644 index 0000000000..2f7d8e30de --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarReportBugFlowTests.kt @@ -0,0 +1,72 @@ +package ch.protonmail.android.uitest.e2e.menu + +import androidx.test.core.app.ApplicationProvider +import ch.protonmail.android.MainActivity +import ch.protonmail.android.initializer.MainInitializer +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.di.LocalhostApi +import ch.protonmail.android.uitest.di.LocalhostApiModule +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule +import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import me.proton.core.report.test.MinimalReportInternalTests +import me.proton.core.test.rule.extension.protonAndroidComposeRule +import org.junit.Before +import org.junit.Rule +import javax.inject.Inject + +@CoreLibraryTest +@HiltAndroidTest +@UninstallModules(LocalhostApiModule::class) +internal class SidebarReportBugFlowTests : MinimalReportInternalTests { + + @JvmField + @BindValue + @LocalhostApi + val localhostApi = false + + @Inject + lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule + + @get:Rule + val protonTestRule = protonAndroidComposeRule( + composeTestRule = ComposeTestRuleHolder.createAndGetComposeRule(), + logoutBefore = false, + fusionEnabled = false, + additionalRules = linkedSetOf(GrantNotificationsPermissionRule()), + afterHilt = { mainInitializer() } + ) + + @Before + fun setup() { + mockOnboardingRuntimeRule(false) + } + + override fun startReport() { + navigator { navigateTo(Destination.Inbox, performLoginViaUI = false) } + + menuRobot { + openSidebarMenu() + openReportBugs() + } + } + + override fun verifyAfter() { + mailboxRobot { verify { isShown() } } + } + + private fun mainInitializer() = runBlocking { + withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt new file mode 100644 index 0000000000..b911f75b6b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/menu/SidebarSubscriptionFlowTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 Proton AG + * This file is part of Proton AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.menu + +import androidx.test.core.app.ApplicationProvider +import ch.protonmail.android.MainActivity +import ch.protonmail.android.initializer.MainInitializer +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.di.LocalhostApi +import ch.protonmail.android.uitest.di.LocalhostApiModule +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import ch.protonmail.android.uitest.rule.GrantNotificationsPermissionRule +import ch.protonmail.android.uitest.rule.MockOnboardingRuntimeRule +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import me.proton.core.plan.test.MinimalSubscriptionTests +import me.proton.core.plan.test.robot.SubscriptionRobot +import me.proton.core.test.rule.extension.protonAndroidComposeRule +import org.junit.Before +import org.junit.Rule +import javax.inject.Inject + +@CoreLibraryTest +@HiltAndroidTest +@UninstallModules(LocalhostApiModule::class) +internal class SidebarSubscriptionFlowTest : MinimalSubscriptionTests() { + + @JvmField + @BindValue + @LocalhostApi + val localhostApi = false + + @Inject + lateinit var mockOnboardingRuntimeRule: MockOnboardingRuntimeRule + + @get:Rule + val protonTestRule = protonAndroidComposeRule( + composeTestRule = ComposeTestRuleHolder.createAndGetComposeRule(), + logoutBefore = false, + fusionEnabled = false, + additionalRules = linkedSetOf(GrantNotificationsPermissionRule()), + afterHilt = { mainInitializer() } + ) + + @Before + fun setup() { + mockOnboardingRuntimeRule(false) + } + + override fun startSubscription(): SubscriptionRobot { + navigator { navigateTo(Destination.Inbox, performLoginViaUI = false) } + mailboxRobot { verify { isShown() } } + + menuRobot { + openSidebarMenu() + openSubscription() + } + + return SubscriptionRobot + } + + private fun mainInitializer() = runBlocking { + withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt new file mode 100644 index 0000000000..0efbe70292 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/onboarding/OnboardingMainTests.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.onboarding + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.TestId +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.login.LoginTestUserTypes +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.onboarding.onboardingRobot +import ch.protonmail.android.uitest.robot.onboarding.section.topBarSection +import ch.protonmail.android.uitest.robot.onboarding.section.bottomSection +import ch.protonmail.android.uitest.robot.onboarding.section.middleSection +import ch.protonmail.android.uitest.robot.onboarding.section.verify +import ch.protonmail.android.uitest.robot.onboarding.verify +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +@UninstallModules(ServerProofModule::class) +internal class OnboardingMainTests : MockedNetworkTest( + showOnboarding = true, + loginType = LoginTestUserTypes.Paid.FancyCapybara +) { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + @Test + @TestId("256675") + fun checkOnboardingScreenShownAtFirstStartup() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher() + + navigator { + navigateTo(Destination.Onboarding) + } + + onboardingRobot { + verify { isShown() } + + topBarSection { + verify { isCloseButtonShown() } + } + + middleSection { + verify { isOnboardingImageShown() } + } + + bottomSection { + verify { isBottomButtonShown() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt new file mode 100644 index 0000000000..693955ffd6 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/settings/SettingsFlowTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.e2e.settings + +import ch.protonmail.android.di.ServerProofModule +import ch.protonmail.android.networkmocks.mockwebserver.combineWith +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.MockedNetworkTest +import ch.protonmail.android.uitest.helpers.core.navigation.Destination +import ch.protonmail.android.uitest.helpers.core.navigation.navigator +import ch.protonmail.android.uitest.helpers.network.mockNetworkDispatcher +import ch.protonmail.android.uitest.robot.menu.MenuRobot +import ch.protonmail.android.uitest.robot.settings.account.verify +import ch.protonmail.android.uitest.robot.settings.swipeactions.verify +import ch.protonmail.android.uitest.robot.settings.verify +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import io.mockk.mockk +import me.proton.core.auth.domain.usecase.ValidateServerProof +import org.junit.Before +import org.junit.Test + +@RegressionTest +@UninstallModules(ServerProofModule::class) +@HiltAndroidTest +internal class SettingsFlowTest : MockedNetworkTest() { + + @JvmField + @BindValue + val serverProofValidation: ValidateServerProof = mockk(relaxUnitFun = true) + + private val menuRobot = MenuRobot() + + @Before + fun setupDispatcher() { + mockWebServer.dispatcher combineWith mockNetworkDispatcher() + navigator { navigateTo(Destination.Inbox) } + } + + @Test + fun openAccountSettings() { + menuRobot + .openSidebarMenu() + .openSettings() + .openUserAccountSettings() + .verify { accountSettingsScreenIsDisplayed() } + } + + @Test + fun openConversationModeSetting() { + menuRobot + .openSidebarMenu() + .openSettings() + .openUserAccountSettings() + .verify { accountSettingsScreenIsDisplayed() } + .openConversationMode() + .verify { conversationModeToggleIsDisplayedAndEnabled() } + } + + @Test + fun openSettingAndChangePreferredTheme() { + menuRobot + .openSidebarMenu() + .openSettings() + .openThemeSettings() + .selectSystemDefault() + .verify { defaultThemeSettingIsSelected() } + .selectDarkTheme() + .verify { darkThemeIsSelected() } + } + + @Test + fun openSettingAndChangePreferredLanguage() { + val languageSettingsRobot = menuRobot + .openSidebarMenu() + .openSettings() + .openLanguageSettings() + .selectSystemDefault() + .verify { defaultLanguageIsSelected() } + .selectSpanish() + .verify { + spanishLanguageIsSelected() + appLanguageChangedToSpanish() + } + .selectBrazilianPortuguese() + .verify { + brazilianPortugueseLanguageIsSelected() + appLanguageChangedToPortuguese() + } + + ComposeTestRuleHolder.rule.waitForIdle() + + /* + * Once Brazilian was selected, we can't just use `selectSystemDefault` to go back to default language, + * since the values returned from `string.mail_settings_system_default` are still the default language ones + * while the app is now in Brazilian, which causes a failure as "System default" string is not found. + * The assumption is that this happens because the Instrumentation's context is not updated when changing lang + */ + languageSettingsRobot + .selectSystemDefaultFromBrazilian() + .verify { defaultLanguageIsSelected() } + } + + @Test + fun openPasswordManagementSettings() { + menuRobot + .openSidebarMenu() + .openSettings() + .openUserAccountSettings() + .openPasswordManagement() + .verify { passwordManagementElementsDisplayed() } + } + + @Test + fun openSettingsAndChangeLeftSwipeAction() { + menuRobot + .openSidebarMenu() + .openSettings() + .openSwipeActions() + .openSwipeLeft() + .selectArchive() + .navigateUpToSwipeActions() + .verify { swipeLeft { isArchive() } } + } + + @Test + fun openSettingsAndChangeRightSwipeAction() { + menuRobot + .openSidebarMenu() + .openSettings() + .openSwipeActions() + .openSwipeRight() + .selectMarkRead() + .navigateUpToSwipeActions() + .verify { swipeRight { isMarkRead() } } + } + + @Test + fun openSettingsAndChangeCombinedContactsSetting() { + menuRobot + .openSidebarMenu() + .openSettings() + .openCombinedContactsSettings() + .turnOnCombinedContacts() + .verify { combinedContactsSettingIsToggled() } + } + + @Test + fun openSettingsAndChangeAlternativeRoutingSetting() { + menuRobot + .openSidebarMenu() + .openSettings() + .openAlternativeRoutingSettings() + .turnOffAlternativeRouting() + .verify { alternativeRoutingSettingIsToggled() } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt new file mode 100644 index 0000000000..b54b94ba6e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/e2e/userrecovery/UserRecoveryFlowTest.kt @@ -0,0 +1,74 @@ +package ch.protonmail.android.uitest.e2e.userrecovery + +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import ch.protonmail.android.uitest.BaseTest +import ch.protonmail.android.uitest.di.LocalhostApi +import ch.protonmail.android.uitest.di.LocalhostApiModule +import ch.protonmail.android.uitest.robot.account.section.buttonsSection +import ch.protonmail.android.uitest.robot.account.signOutAccountDialogRobot +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.verify +import ch.protonmail.android.uitest.robot.menu.menuRobot +import ch.protonmail.android.uitest.util.awaitProgressIsHidden +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import me.proton.core.auth.test.usecase.WaitForPrimaryAccount +import me.proton.core.domain.entity.UserId +import me.proton.core.test.quark.Quark +import me.proton.core.userrecovery.dagger.CoreDeviceRecoveryFeaturesModule +import me.proton.core.userrecovery.domain.IsDeviceRecoveryEnabled +import me.proton.core.userrecovery.domain.repository.DeviceRecoveryRepository +import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryHandler +import me.proton.core.userrecovery.presentation.compose.DeviceRecoveryNotificationSetup +import me.proton.core.userrecovery.test.MinimalUserRecoveryTest +import javax.inject.Inject +import kotlin.test.BeforeTest + +@CoreLibraryTest +@HiltAndroidTest +@UninstallModules( + LocalhostApiModule::class, + CoreDeviceRecoveryFeaturesModule::class +) +internal class UserRecoveryFlowTest : BaseTest(), MinimalUserRecoveryTest { + override val quark: Quark = BaseTest.quark + + @JvmField + @BindValue + @LocalhostApi + val localhostApi = false + + @Inject + override lateinit var deviceRecoveryHandler: DeviceRecoveryHandler + + @Inject + override lateinit var deviceRecoveryNotificationSetup: DeviceRecoveryNotificationSetup + + @Inject + override lateinit var deviceRecoveryRepository: DeviceRecoveryRepository + + @Inject + override lateinit var waitForPrimaryAccount: WaitForPrimaryAccount + + @BindValue + internal val isDeviceRecoveryEnabled = object : IsDeviceRecoveryEnabled { + override fun invoke(userId: UserId?): Boolean = true + override fun isLocalEnabled(): Boolean = true + override fun isRemoteEnabled(userId: UserId?): Boolean = true + } + + @BeforeTest + override fun prepare() { + super.prepare() + initFusion(composeTestRule) + } + + override fun signOut() { + menuRobot { openSidebarMenu() } + menuRobot { tapSignOut() } + signOutAccountDialogRobot { + buttonsSection { tapSignOut() } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt new file mode 100644 index 0000000000..f42b89d424 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/CoreLibraryTestFilter.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.filters + +import androidx.test.filters.AbstractFilter +import ch.protonmail.android.test.annotations.suite.CoreLibraryTest +import org.junit.runner.Description + +@Suppress("unused") // Used as CLI parameter +internal class CoreLibraryTestFilter : AbstractFilter() { + + override fun shouldRun(description: Description): Boolean = super.shouldRun(description) + + override fun describe(): String = "Filters core library tests only" + + override fun evaluateTest(description: Description): Boolean { + return description.hasAnnotation(CoreLibraryTest::class.java) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt new file mode 100644 index 0000000000..18b52124e3 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/DescriptionExtension.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.filters + +import org.junit.runner.Description + +/** + * Checks whether the given [Description] has a specific [Annotation], either at the method OR test class level. + * + * @param annotation an annotation class. + * @return true if the annotation is present. + */ +internal fun Description.hasAnnotation(annotation: Class): Boolean { + return getAnnotation(annotation) != null || testClass.isAnnotationPresent(annotation) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt new file mode 100644 index 0000000000..c1853a1939 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/FullRegressionTestFilter.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.filters + +import androidx.test.filters.AbstractFilter +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.test.annotations.suite.SmokeTest +import org.junit.runner.Description + +@Suppress("unused") // Used as CLI parameter +internal class FullRegressionTestFilter : AbstractFilter() { + + override fun shouldRun(description: Description): Boolean = super.shouldRun(description) + + override fun describe(): String = "Run full regression tests" + + override fun evaluateTest(description: Description): Boolean { + return description.hasAnnotation(RegressionTest::class.java) || + description.hasAnnotation(SmokeTest::class.java) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt new file mode 100644 index 0000000000..cb4475caf9 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/filters/SmokeTestFilter.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.filters + +import androidx.test.filters.AbstractFilter +import ch.protonmail.android.test.annotations.suite.SmokeTest +import org.junit.runner.Description + +@Suppress("unused") // Used as CLI parameter +internal class SmokeTestFilter : AbstractFilter() { + + override fun shouldRun(description: Description): Boolean = super.shouldRun(description) + + override fun describe(): String = "Filters smoke tests only" + + override fun evaluateTest(description: Description): Boolean { + return description.hasAnnotation(SmokeTest::class.java) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt new file mode 100644 index 0000000000..f7eb50dd57 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/AppThemeHelper.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core + +import androidx.datastore.preferences.core.stringPreferencesKey +import ch.protonmail.android.mailcommon.data.mapper.safeEdit +import ch.protonmail.android.mailsettings.data.MailSettingsDataStoreProvider +import ch.protonmail.android.mailsettings.domain.model.Theme +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * A class helper to force a certain theme in the app. + */ +internal class AppThemeHelper @Inject constructor() { + + @Inject + lateinit var dataStoreProvider: MailSettingsDataStoreProvider + + private val themePreferenceKey = stringPreferencesKey("themeEnumNamePrefKey") + + fun applyTheme(theme: Theme) = runBlocking { + dataStoreProvider.themeDataStore.safeEdit { + it[themePreferenceKey] = theme.name + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt new file mode 100644 index 0000000000..46e1797f8f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestId.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core + +/** + * A custom annotation used to uniquely identify tests implementation in the codebase + * and cross-reference them with the definitions in our test management tool. + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class TestId(vararg val values: String) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt new file mode 100644 index 0000000000..7585b89b00 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestIdWatcher.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core + +import java.util.logging.Logger +import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import me.proton.core.presentation.utils.showToast +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A custom [TestWatcher] that logs the beginning and the end of a test execution along with its [TestId]s (if any). + * + * At the beginning of the test, it also shows a Toast to help cross-reference the implementation + * with the scenario described in the test management tool. + */ +internal class TestIdWatcher : TestWatcher() { + + private val Description.testIds: String? + get() { + val annotation = annotations.find { it is TestId } as? TestId + return annotation?.values?.joinToString() + } + + private val Description.classMethodName: String + get() = "$className#$methodName" + + override fun starting(description: Description) { + super.starting(description) + + val testIds = description.testIds ?: return + + // Needs to run on the main thread, otherwise it won't show anything. + runBlocking(Dispatchers.Main) { + instrumentation.targetContext.showToast("Test ID(s) - $testIds", DefaultToastLength) + } + + logger.info("Starting Test ID(s) $testIds - ${description.classMethodName}.") + } + + override fun finished(description: Description) { + super.finished(description) + + description.testIds?.let { testIds -> + logger.info("Finished Test ID(s) $testIds - ${description.classMethodName}.") + } + } + + companion object { + + private val logger = Logger.getLogger(this::class.java.name) + private const val DefaultToastLength = 5000 + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt new file mode 100644 index 0000000000..653fa4c056 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/TestingNotes.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core + +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +internal annotation class TestingNotes(@Suppress("unused") val note: String) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt new file mode 100644 index 0000000000..03c013bd9a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Destination.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core.navigation + +/** + * A [Destination] represents a screen of the Proton Mail app. + */ +internal sealed class Destination { + + object Onboarding : Destination() + object Inbox : Destination() + object Drafts : Destination() + object Archive : Destination() + object Spam : Destination() + object Trash : Destination() + object Composer : Destination() + class MailDetail(val messagePosition: Int = 0) : Destination() + class EditDraft(val draftPosition: Int = 0) : Destination() + object SidebarMenu : Destination() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt new file mode 100644 index 0000000000..dccc7bec78 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/core/navigation/Navigator.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.core.navigation + +import androidx.test.espresso.Espresso +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.uitest.helpers.login.MockedLoginTestUsers +import ch.protonmail.android.uitest.robot.common.section.fullscreenLoaderSection +import ch.protonmail.android.uitest.robot.composer.composerRobot +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.topAppBarSection +import ch.protonmail.android.uitest.robot.menu.menuRobot +import ch.protonmail.android.uitest.util.ActivityScenarioHolder +import ch.protonmail.android.uitest.util.extensions.waitUntilSignInScreenIsGone +import me.proton.core.test.android.robots.auth.AddAccountRobot +import me.proton.core.test.android.robots.auth.login.LoginRobot + +/** + * An abstraction to help navigating the app in UI tests to reduce the overall verbosity. + */ +@AsDsl +internal class Navigator { + + private val addAccountRobot = AddAccountRobot() + + /** + * Triggers the launch of the app and waits for an idle state (via Espresso). + * + * The Compose test rule here is not used as the entry point when launching the app + * will never contain a Compose hierarchy for now (as it's the common Core sign in/up screen). + */ + fun openApp() { + ActivityScenarioHolder.initialize() + Espresso.onIdle() + } + + /** + * Navigates to a given [Destination]. + * + * The navigation shall always be performed at the beginning of the test, as it assumes that the initial state + * will always either be the "Add account" screen (from the Core library) or the Inbox. + * + * @param destination the destination + * @param launchApp whether the app shall be launched. + * @param performLoginViaUI whether the login flow shall be performed via UI + */ + fun navigateTo( + destination: Destination, + launchApp: Boolean = true, + performLoginViaUI: Boolean = true + ) { + if (launchApp) openApp() + + if (performLoginViaUI) login() + + when (destination) { + is Destination.Onboarding, + is Destination.Inbox -> Unit // It's the default screen post-login, nothing to do. + is Destination.Drafts -> menuRobot { + openSidebarMenu() + openDrafts() + } + + is Destination.Archive -> menuRobot { + openSidebarMenu() + openArchive() + } + + is Destination.Spam -> menuRobot { + openSidebarMenu() + openSpam() + } + + is Destination.Trash -> menuRobot { + openSidebarMenu() + openTrash() + } + + is Destination.Composer -> mailboxRobot { + topAppBarSection { tapComposerIcon() } + } + + is Destination.MailDetail -> mailboxRobot { + listSection { clickMessageByPosition(destination.messagePosition) } + } + + is Destination.EditDraft -> { + navigateTo(Destination.Drafts, launchApp = false, performLoginViaUI = false) + mailboxRobot { listSection { clickMessageByPosition(destination.draftPosition) } } + composerRobot { fullscreenLoaderSection { waitUntilGone() } } + } + + is Destination.SidebarMenu -> menuRobot { openSidebarMenu() } + } + } + + private fun login() { + addAccountRobot + .signIn() + .loginUser(MockedLoginTestUsers.defaultLoginUser) + .waitUntilSignInScreenIsGone() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt new file mode 100644 index 0000000000..6270cc1209 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginTestUserTypes.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.login + +object LoginTestUserTypes { + object Free { + + val SleepyKoala = LoginType.LoggedIn("slpkla") + } + + object Paid { + + val FancyCapybara = LoginType.LoggedIn("fncyra") + } + + object External { + + val StrangeWalrus = LoginType.LoggedIn("stgwrs") + } + + object Deprecated { + + val GrumpyCat = LoginType.LoggedIn("gmpcat") + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt new file mode 100644 index 0000000000..27d7a23124 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/LoginType.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.login + +sealed class LoginType { + + class LoggedIn(val id: String) : LoginType() + object LoggedOut : LoginType() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt new file mode 100644 index 0000000000..446afdac69 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/login/MockedLoginTestUsers.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.login + +import me.proton.core.test.quark.data.User + +internal object MockedLoginTestUsers { + + val defaultLoginUser = User( + name = "fake.user", + password = "password", + passphrase = "password" + ) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt new file mode 100644 index 0000000000..c5f97a0c31 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/AuthenticationDispatcher.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.network + +import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode +import ch.protonmail.android.uitest.helpers.login.LoginType + +/** + * Returns a [MockNetworkDispatcher] instance with valid authenticator mocks depending on the passed [LoginType]. + * + * @param loginType the login type (logged in, out). + * + * @return an instance of [MockNetworkDispatcher] with predefined mock definitions. + */ +internal fun authenticationDispatcher(loginType: LoginType) = MockNetworkDispatcher().apply { + val id = when (loginType) { + is LoginType.LoggedIn -> loginType.id + else -> return@apply + } + + addMockRequests( + post("/auth/v4") respondWith "/auth/v4/auth-v4_$id.json" withStatusCode 200, + post("/auth/v4/info") respondWith "/auth/v4/info/info_$id.json" withStatusCode 200, + post("/auth/v4/sessions") respondWith "/auth/v4/sessions/sessions_$id.json" withStatusCode 200, + get("/core/v4/users") respondWith "/core/v4/users/users_$id.json" withStatusCode 200, + get("/core/v4/addresses") respondWith "/core/v4/addresses/addresses_$id.json" withStatusCode 200, + get("/core/v4/keys/salts") respondWith "/core/v4/keys/salts/salts_$id.json" withStatusCode 200, + get("/auth/v4/scopes") respondWith "/auth/v4/scopes/scopes_$id.json" withStatusCode 200 + ) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt new file mode 100644 index 0000000000..0c35d2bf52 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/DefaultNetworkDispatcher.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +@file:SuppressWarnings("MagicNumber", "MaxLineLength", "LongParameterList") + +package ch.protonmail.android.uitest.helpers.network + +import ch.protonmail.android.networkmocks.mockwebserver.MockNetworkDispatcher +import ch.protonmail.android.networkmocks.mockwebserver.requests.MockPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.get +import ch.protonmail.android.networkmocks.mockwebserver.requests.ignoreQueryParams +import ch.protonmail.android.networkmocks.mockwebserver.requests.matchWildcards +import ch.protonmail.android.networkmocks.mockwebserver.requests.post +import ch.protonmail.android.networkmocks.mockwebserver.requests.put +import ch.protonmail.android.networkmocks.mockwebserver.requests.respondWith +import ch.protonmail.android.networkmocks.mockwebserver.requests.withPriority +import ch.protonmail.android.networkmocks.mockwebserver.requests.withStatusCode + +/** + * A base top level function that provides a [MockNetworkDispatcher] instance + * with default values that can be easily overridden. + */ +internal fun mockNetworkDispatcher( + useDefaultCoreSettings: Boolean = true, + useDefaultMailSettings: Boolean = true, + useDefaultContacts: Boolean = true, + useDefaultFeatures: Boolean = true, + useDefaultUnleashToggles: Boolean = true, + useDefaultLabels: Boolean = true, + useDefaultContactGroups: Boolean = true, + useDefaultCustomFolders: Boolean = true, + useDefaultSystemFolders: Boolean = true, + useDefaultPaymentSettings: Boolean = true, + useDefaultMailReadResponses: Boolean = true, + useDefaultDeviceRegistration: Boolean = true, + useDefaultCounters: Boolean = true, + ignoreEvents: Boolean = true, + additionalMockDefinitions: MockNetworkDispatcher.() -> Unit = {} +) = MockNetworkDispatcher().apply { + + if (useDefaultCoreSettings) { + addMockRequests( + get("/core/v4/settings") + respondWith "/core/v4/settings/core-v4-settings_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultMailSettings) { + addMockRequests( + get("/mail/v4/settings") + respondWith "/mail/v4/settings/mail-v4-settings_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultContacts) { + addMockRequests( + get("/contacts/v4/contacts") + respondWith "/contacts/v4/contacts/contacts_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true, + get("/contacts/v4/contacts/emails") + respondWith "/contacts/v4/contacts/emails/contacts-emails_base_placeholder.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + if (useDefaultFeatures) { + addMockRequests( + get("/core/v4/features") + respondWith "/core/v4/features/features_empty_placeholder.json" + withStatusCode 200 ignoreQueryParams true + ) + } + + if (useDefaultUnleashToggles) { + addMockRequests( + get("/feature/v2/frontend") + respondWith "/feature/v2/frontend/frontend_empty_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultLabels) { + addMockRequests( + get("/core/v4/labels?Type=1") + respondWith "/core/v4/labels/labels-type1_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultContactGroups) { + addMockRequests( + get("/core/v4/labels?Type=2") + respondWith "/core/v4/labels/labels-type2_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultCustomFolders) { + addMockRequests( + get("/core/v4/labels?Type=3") + respondWith "/core/v4/labels/labels-type3_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultSystemFolders) { + addMockRequests( + get("/core/v4/labels?Type=4") + respondWith "/core/v4/labels/labels-type4_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultPaymentSettings) { + addMockRequests( + get("/payments/v4/status/google") + respondWith "/payments/v4/status/google/payments_empty.json" + withStatusCode 200 + ) + } + + if (useDefaultDeviceRegistration) { + addMockRequests( + post("/core/v4/devices") + respondWith "/core/v4/devices/devices_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultMailReadResponses) { + addMockRequests( + put("/mail/v4/messages/read") + respondWith "/mail/v4/messages/read/read_base_placeholder.json" + withStatusCode 200 withPriority MockPriority.Highest, + put("/mail/v4/conversations/read") + respondWith "/mail/v4/conversations/read/conversations_read_base_placeholder.json" + withStatusCode 200 withPriority MockPriority.Highest + ) + } + + if (ignoreEvents) { + addMockRequests( + get("/core/v5/events/*") + respondWith "/core/v5/events/event-id/event-v5_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/core/v5/events/latest") + respondWith "/core/v5/events/latest/events-v5-latest_base_placeholder.json" + withStatusCode 200, + get("/core/v4/events/*") + respondWith "/core/v4/events/event-id/event_base_placeholder.json" + withStatusCode 200 matchWildcards true, + get("/core/v4/events/latest") + respondWith "/core/v4/events/latest/events-latest_base_placeholder.json" + withStatusCode 200 + ) + } + + if (useDefaultCounters) { + addMockRequests( + get("/mail/v4/conversations/count") + respondWith "/mail/v4/conversations/count/conversations-count_base_placeholder.json" + withStatusCode 200, + get("/mail/v4/messages/count") + respondWith "/mail/v4/messages/count/messages-count_base_placeholder.json" + withStatusCode 200 + ) + } + + additionalMockDefinitions() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt new file mode 100644 index 0000000000..c5026e06b1 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/helpers/network/NetworkManagerExtensions.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.helpers.network + +import io.mockk.every +import kotlinx.coroutines.flow.flowOf +import me.proton.core.network.domain.NetworkManager +import me.proton.core.network.domain.NetworkStatus + +internal fun NetworkManager.enableNetwork() { + every { observe() } returns flowOf(NetworkStatus.Unmetered) + every { networkStatus } returns NetworkStatus.Unmetered + every { isConnectedToNetwork() } returns true +} + +internal fun NetworkManager.disableNetwork() { + every { observe() } returns flowOf(NetworkStatus.Disconnected) + every { networkStatus } returns NetworkStatus.Disconnected + every { isConnectedToNetwork() } returns false +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt new file mode 100644 index 0000000000..868dce9ff7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/avatar/AvatarInitial.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.avatar + +sealed class AvatarInitial { + + object Draft : AvatarInitial() + class WithText(val text: String) : AvatarInitial() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt new file mode 100644 index 0000000000..39818ac412 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntry.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.bottombar + +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +// Every inheritor has a default index which reflects the current implementation in App. +internal sealed class BottomBarActionEntry(val index: Int, val description: String) { + + class MarkAsRead(index: Int = 0) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_mark_read_content_description)) + + class MarkAsUnread(index: Int = 0) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_mark_unread_content_description)) + + class Trash(index: Int = 1) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_trash_content_description)) + + class Delete(index: Int = 1) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_delete_content_description)) + + class MoveTo(index: Int = 2) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_move_content_description)) + + class LabelAs(index: Int = 3) : + BottomBarActionEntry(index, getTestString(testR.string.test_action_label_content_description)) + + object More : + BottomBarActionEntry(index = LastItemIndex, getTestString(testR.string.test_action_more_content_description)) + + object Defaults { + + val actionsOnReadItem = arrayOf(MarkAsUnread(), Trash(), MoveTo(), LabelAs(), More) + val actionsOnUnreadItem = arrayOf(MarkAsRead(), Trash(), MoveTo(), LabelAs(), More) + val actionsOnTrashedReadItem = arrayOf(MarkAsUnread(), Delete(), MoveTo(), LabelAs(), More) + val actionsOnTrashedUnreadItem = arrayOf(MarkAsRead(), Delete(), MoveTo(), LabelAs(), More) + val actionsOnSpamReadItem = arrayOf(MarkAsUnread(), Delete(), MoveTo(), LabelAs(), More) + val actionsOnSpamUnreadItem = arrayOf(MarkAsRead(), Delete(), MoveTo(), LabelAs(), More) + } + + private companion object { + + private const val TotalItemsThreshold = 5 + private const val LastItemIndex = TotalItemsThreshold - 1 + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt new file mode 100644 index 0000000000..0fd731a067 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/bottombar/BottomBarActionEntryModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.bottombar + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBarTestTags +import ch.protonmail.android.uitest.util.child + +internal class BottomBarActionEntryModel(private val index: Int, parent: SemanticsNodeInteraction) { + + private val item = parent.child { hasTestTag("${BottomActionBarTestTags.Button}$index") } + + // region actions + fun click() = item.performClick() + // endregion + + // region verification + fun hasDescription(description: String) = item.assertContentDescriptionEquals(description) + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt new file mode 100644 index 0000000000..04af91824e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntry.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.detail + +internal enum class RecipientKind { + To, + Cc, + Bcc +} + +internal sealed class ExtendedHeaderRecipientEntry( + val kind: RecipientKind, + val index: Int, + val name: String, + val address: String +) { + + class To( + index: Int, + name: String, + address: String + ) : ExtendedHeaderRecipientEntry( + kind = RecipientKind.To, + index = index, + name = name, + address = address + ) + + class Cc( + index: Int, + name: String, + address: String + ) : ExtendedHeaderRecipientEntry( + kind = RecipientKind.Cc, + index = index, + name = name, + address = address + ) + + class Bcc( + index: Int, + name: String, + address: String + ) : ExtendedHeaderRecipientEntry( + kind = RecipientKind.Bcc, + index = index, + name = name, + address = address + ) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt new file mode 100644 index 0000000000..f179a92a8c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRecipientEntryModel.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.detail + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags +import ch.protonmail.android.uitest.util.children + +internal class ExtendedHeaderRecipientEntryModel( + parent: SemanticsNodeInteraction, + index: Int +) { + + private val name = parent.children { + hasTestTag(MessageDetailHeaderTestTags.ParticipantName) + }[index] + + private val address = parent.children { + hasTestTag(MessageDetailHeaderTestTags.ParticipantValue) + }[index] + + fun hasName(value: String) = apply { + name.assertTextEquals(value) + } + + fun hasAddress(value: String) = apply { + address.assertTextEquals(value) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt new file mode 100644 index 0000000000..fc5253ff25 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/ExtendedHeaderRowEntryModel.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.detail + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags +import ch.protonmail.android.uitest.util.child + +internal class ExtendedHeaderRowEntryModel(parent: SemanticsNodeInteraction) { + + private val icon = parent.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedHeaderIcon) + } + + private val text = parent.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedHeaderText) + } + + // region verification + fun hasIcon() = apply { + icon.assertExists() + } + + fun hasText(value: String) = apply { + text.assertTextEquals(value) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt new file mode 100644 index 0000000000..6acbeddc3f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderEntryModel.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags +import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailItemTestTags +import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.labels.LabelEntry +import ch.protonmail.android.uitest.models.labels.LabelEntryModel +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString + +@Suppress("TooManyFunctions") +internal class MessageHeaderEntryModel( + composeTestRule: ComposeTestRule +) { + + private val collapseAnchor = composeTestRule.onNodeWithTag( + testTag = ConversationDetailItemTestTags.CollapseAnchor + ) + + private val rootItem = composeTestRule.onNodeWithTag( + testTag = MessageDetailHeaderTestTags.RootItem, + useUnmergedTree = true + ) + + private val quickActionsItem = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ActionsRootItem) + } + + private val avatarRootItem = rootItem.child { + hasTestTag(AvatarTestTags.AvatarRootItem) + } + + private val avatar = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarText) + } + + private val avatarDraft = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarDraft) + } + + private val senderName = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.SenderName) + } + + private val senderAddress = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.SenderAddress) + } + + private val authenticityBadge = rootItem.child { + hasTestTag(OfficialBadgeTestTags.Item) + } + + private val icons = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.Icons) + } + + private val time = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.Time) + } + + private val replyButton = quickActionsItem.child { + hasTestTag(MessageDetailHeaderTestTags.ReplyButton) + } + + private val replyAllButton = quickActionsItem.child { + hasTestTag(MessageDetailHeaderTestTags.ReplyAllButton) + } + + private val moreButton = quickActionsItem.child { + hasTestTag(MessageDetailHeaderTestTags.MoreButton) + } + + private val recipientsText = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.AllRecipientsText) + } + + private val recipientsValue = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.AllRecipientsValue) + } + + private val labelsList = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.LabelsList) + } + + // region actions + fun click() = apply { + rootItem.performClick() + } + + fun tapReplyButton() = apply { + replyButton.performClick() + } + + fun collapseMessage() = apply { + collapseAnchor.performClick() + } + // endregion + + // region verification + fun isDisplayed() = apply { + rootItem.assertExists() + } + + fun hasAvatar(initial: AvatarInitial) = apply { + when (initial) { + is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text) + is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed() + } + } + + fun hasSenderName(name: String) = apply { + senderName.assertTextEquals(name) + } + + fun hasAuthenticityBadge(expectedValue: Boolean) { + if (expectedValue) { + authenticityBadge.assertIsDisplayed() + authenticityBadge.assertTextEquals(getTestString(R.string.test_auth_badge_official)) + } else { + authenticityBadge.assertDoesNotExist() + } + } + + fun hasSenderAddress(address: String) = apply { + senderAddress.assertTextEquals(address) + } + + fun hasMoreButton() = apply { + moreButton.assertIsDisplayed() + } + + fun hasIcons() = apply { + icons.assertIsDisplayed() + } + + fun hasNoIcons() = apply { + icons.assertIsNotDisplayed() + } + + fun hasDate(date: String) = apply { + time.assertTextEquals(date) + } + + fun hasRecipient(recipient: String) = apply { + recipientsText.assertIsDisplayed() + recipientsValue.assertTextEquals(recipient) + } + + fun hasLabels(vararg entries: LabelEntry) = apply { + entries.forEach { + val model = LabelEntryModel(labelsList, it.index) + model.hasText(it.text) + } + } + + fun hasReplyButton() = apply { + replyButton.assertIsDisplayed() + } + + fun hasReplyAllButton() = apply { + replyAllButton.assertIsDisplayed() + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt new file mode 100644 index 0000000000..27628a536c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/detail/MessageHeaderExpandedEntryModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.detail + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailItemTestTags +import ch.protonmail.android.maildetail.presentation.ui.header.MessageDetailHeaderTestTags +import ch.protonmail.android.uitest.models.labels.LabelEntry +import ch.protonmail.android.uitest.models.labels.LabelEntryModel +import ch.protonmail.android.uitest.util.child + +internal class MessageHeaderExpandedEntryModel(composeTestRule: ComposeTestRule) { + + // In the layout, it's outside of the root item + private val collapseAnchor = composeTestRule.onNodeWithTag( + testTag = ConversationDetailItemTestTags.CollapseAnchor + ) + + private val rootItem = composeTestRule.onNodeWithTag( + testTag = MessageDetailHeaderTestTags.RootItem, + useUnmergedTree = true + ) + + // The structure of the Labels component is a bit different than the others. + private val labels = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedLabelRow) + } + + private val time = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedTimeRow) + }.asExtendedHeaderRowEntryModel() + + private val location = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedFolderRow) + }.asExtendedHeaderRowEntryModel() + + private val size = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedSizeRow) + }.asExtendedHeaderRowEntryModel() + + private val hideDetailsButton = rootItem.child { + hasTestTag(MessageDetailHeaderTestTags.ExtendedHideDetails) + } + + // region actions + fun collapse() { + collapseAnchor.performClick() + } + // endregion + + // region verification + fun hasRecipients(vararg recipients: ExtendedHeaderRecipientEntry) { + recipients.forEach { + val model = it.asEntryModel() + model.hasName(it.name) + .hasAddress(it.address) + } + } + + fun hasLabels(vararg entries: LabelEntry) { + labels.child { hasTestTag(MessageDetailHeaderTestTags.LabelIcon) }.assertExists() + + entries.forEach { + val model = LabelEntryModel(labels, it.index) + model.hasText(it.text) + } + } + + fun hasTime(value: String) { + time.hasIcon() + .hasText(value) + } + + fun hasLocation(value: String) { + location.hasIcon() + .hasText(value) + } + + fun hasSize(value: String) { + size.hasIcon() + .hasText(value) + } + + fun hasHideDetailsButton() { + hideDetailsButton.assertIsDisplayed() + } + // endregion + + private fun ExtendedHeaderRecipientEntry.asEntryModel(): ExtendedHeaderRecipientEntryModel { + val testTag = when (kind) { + RecipientKind.To -> MessageDetailHeaderTestTags.ToRecipientsList + RecipientKind.Cc -> MessageDetailHeaderTestTags.CcRecipientsList + RecipientKind.Bcc -> MessageDetailHeaderTestTags.BccRecipientsList + } + + val parent = rootItem.child { hasTestTag(testTag) } + return ExtendedHeaderRecipientEntryModel(parent, index) + } + + private fun SemanticsNodeInteraction.asExtendedHeaderRowEntryModel() = ExtendedHeaderRowEntryModel(this) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt new file mode 100644 index 0000000000..e1baa9c9eb --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailFolderEntry.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +internal data class MailFolderEntry( + val index: Int, + val iconTint: Tint +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt new file mode 100644 index 0000000000..649361af63 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/MailLabelEntry.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +internal data class MailLabelEntry( + val index: Int, + val name: String, + val backgroundTint: Tint = Tint.NoColor +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt new file mode 100644 index 0000000000..a7e30ff9ab --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarCustomItemEntry.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +internal data class SidebarCustomItemEntry( + val index: Int, + val name: String, + val iconTint: Tint +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt new file mode 100644 index 0000000000..061645566d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemCustomEntryModels.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +import androidx.compose.ui.test.hasTestTag + +internal class SidebarItemCustomLabelEntryModel( + position: Int +) : SidebarItemEntryModel(position, hasTestTag(TestTags.SidebarItemCustomLabel)) + +internal class SidebarItemCustomFolderEntryModel( + position: Int +) : SidebarItemEntryModel(position, hasTestTag(TestTags.SidebarItemCustomFolder)) + +private object TestTags { + + const val SidebarItemCustomFolder = "SidebarItemCustomFolder" + const val SidebarItemCustomLabel = "SidebarItemCustomLabel" +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt new file mode 100644 index 0000000000..fd13bd25ce --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/SidebarItemEntryModel.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import ch.protonmail.android.maillabel.presentation.sidebar.SidebarCustomLabelTestTags +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.util.assertions.assertTintColor +import ch.protonmail.android.uitest.util.child + +internal sealed class SidebarItemEntryModel( + position: Int, + matcher: SemanticsMatcher, + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) { + + private val rootItem = composeTestRule.onAllNodes( + matcher = matcher, + useUnmergedTree = true + )[position] + + private val icon = rootItem.child { + hasTestTag(SidebarCustomLabelTestTags.Icon) + } + + private val text = rootItem.child { + hasTestTag(SidebarCustomLabelTestTags.Text) + } + + fun hasText(value: String) = apply { + text.assertTextEquals(value) + } + + fun withIconTint(tint: Tint) = apply { + icon.assertTintColor(tint) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt new file mode 100644 index 0000000000..f53b2df023 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/folders/Tint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.folders + +import androidx.compose.ui.graphics.Color + +internal sealed class Tint { + object NoColor : Tint() + + sealed class WithColor(val value: Color) : Tint() { + object Carrot : WithColor(Color(0xFFF78400)) + object Fern : WithColor(Color(0xFF3CBB3A)) + object PurpleBase : WithColor(Color(0xFF8080FF)) + object Bridge : WithColor(Color(0xFFFF6666)) + class Custom(hex: Long) : WithColor(Color(hex)) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt new file mode 100644 index 0000000000..2af0b9209d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntry.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.labels + +internal data class LabelEntry( + val index: Int, + val text: String +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt new file mode 100644 index 0000000000..cd1466ad3d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/labels/LabelEntryModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.labels + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import ch.protonmail.android.maillabel.presentation.ui.LabelsListTestTags +import ch.protonmail.android.uitest.util.children + +internal class LabelEntryModel( + parent: SemanticsNodeInteraction, + index: Int, +) { + + private val labelItem = parent.children { + hasTestTag(LabelsListTestTags.Label) + }[index] + + // region actions + fun hasText(text: String) = apply { + labelItem.assertTextEquals(text) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt new file mode 100644 index 0000000000..94ade85393 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntry.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.mailbox + +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.MailFolderEntry +import ch.protonmail.android.uitest.models.folders.MailLabelEntry + +internal data class MailboxListItemEntry( + val index: Int, + val avatarInitial: AvatarInitial, + val participants: List, + val locationIcons: List? = null, + val labels: List? = null, + val subject: String, + val date: String, + val count: String? = null +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt new file mode 100644 index 0000000000..7273aab2b5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxListItemEntryModel.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.mailbox + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxItemTestTags +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.MailFolderEntry +import ch.protonmail.android.uitest.models.folders.MailLabelEntry +import ch.protonmail.android.uitest.util.assertions.assertItemIsRead +import ch.protonmail.android.uitest.util.assertions.assertTintColor +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child +import kotlin.time.Duration.Companion.seconds + +internal class MailboxListItemEntryModel( + private val position: Int, + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) { + + private val parent: SemanticsNodeInteraction = composeTestRule.onNodeWithTag( + MailboxScreenTestTags.List, + useUnmergedTree = true + ) + + private val itemMatcher: SemanticsMatcher + get() = hasTestTag("${MailboxItemTestTags.ItemRow}$position") + + private val item: SemanticsNodeInteraction = parent.child { itemMatcher } + + private val avatarRootItem = item.child { + hasTestTag(AvatarTestTags.AvatarRootItem) + } + + private val avatar = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarText) + } + + private val avatarDraft = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarDraft) + } + + private val avatarSelected = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarSelectionMode) + } + + private val locations = item.child { + hasTestTag(MailboxItemTestTags.LocationIcons) + } + + private val labels = item.child { + hasTestTag(MailboxItemTestTags.LabelsList) + } + + private val subject = item.child { + hasTestTag(MailboxItemTestTags.Subject) + } + + private val date = item.child { + hasTestTag(MailboxItemTestTags.Date) + } + + private val count = item.child { + hasTestTag(MailboxItemTestTags.Count) + } + + init { + waitForItemToBeShown() + } + + // region actions + fun click() = apply { + item.performClick() + } + + fun longClick() = apply { + item.performTouchInput { longClick() } + } + + fun selectEntry() = apply { + val semanticsNode = if (avatarSelected.peekIsDisplayed()) avatarSelected else avatar + semanticsNode.performClick() + } + + fun unselectEntry() = apply { + avatarSelected.performClick() + } + // endregion + + // region verification + fun isSelected() = apply { + avatarSelected.assertIsDisplayed().assertIsSelected() + } + + fun isNotSelected() = apply { + if (avatarSelected.peekIsDisplayed()) { + avatarSelected.assertIsNotSelected() + avatar.assertDoesNotExist() + } else { + avatarSelected.assertDoesNotExist() + avatar.assertIsDisplayed() + } + } + + fun hasAvatar(initial: AvatarInitial) = apply { + when (initial) { + is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text) + is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed() + } + } + + fun hasParticipants(participants: List) = apply { + participants.forEachIndexed { index, participant -> + val model = ParticipantEntryModel(index, item) + + when (participant) { + is ParticipantEntry.NoSender, + is ParticipantEntry.NoRecipient -> model.hasNoParticipant(participant.value) + + is ParticipantEntry.WithParticipant -> { + model.hasParticipant(participant.value) + .isProton(participant.isProton) + } + } + } + } + + fun hasLocationIcons(entries: List) = apply { + for (entry in entries) { + val folderIcon = locations.onChildAt(entry.index) + folderIcon.assertTintColor(entry.iconTint) + } + } + + fun hasNoLocationIcons() = apply { + locations.assertDoesNotExist() + } + + fun hasSubject(text: String) = apply { + subject.assertTextEquals(text) + } + + fun hasLabels(entries: List) = apply { + for (entry in entries) { + val label = labels.onChildAt(entry.index) + label.assertTextEquals(entry.name) + } + } + + fun hasNoLabels() = apply { + labels.assertIsNotDisplayed() + } + + fun hasDate(text: String) = apply { + date.assertTextEquals(text) + } + + fun hasCount(text: String) = apply { + count.assertTextEquals(text) + } + + fun hasNoCount() = apply { + count.assertDoesNotExist() + } + + fun assertRead() = apply { + item.assertItemIsRead(expectedValue = true) + } + + fun assertUnread() = apply { + item.assertItemIsRead(expectedValue = false) + } + // endregion + + // region helpers + private fun waitForItemToBeShown() = apply { + parent + .awaitDisplayed(timeout = 30.seconds) + .performScrollToNode(itemMatcher) + } + + private fun SemanticsNodeInteraction.peekIsDisplayed() = runCatching { assertIsDisplayed() }.isSuccess + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt new file mode 100644 index 0000000000..8755df1703 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/MailboxType.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.mailbox + +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.util.getTestString + +internal sealed class MailboxType(val name: String) { + + object Inbox : MailboxType(getTestString(R.string.test_label_title_inbox)) + object Drafts : MailboxType(getTestString(R.string.test_label_title_drafts)) + object Sent : MailboxType(getTestString(R.string.test_label_title_sent)) + object Starred : MailboxType(getTestString(R.string.test_label_title_starred)) + object Archive : MailboxType(getTestString(R.string.test_label_title_archive)) + object Spam : MailboxType(getTestString(R.string.test_label_title_spam)) + object Trash : MailboxType(getTestString(R.string.test_label_title_trash)) + object AllMail : MailboxType(getTestString(R.string.test_label_title_all_mail)) + + class CustomLabel(name: String) : MailboxType(name) + class CustomFolder(name: String) : MailboxType(name) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt new file mode 100644 index 0000000000..2b08aa8da2 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntry.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.mailbox + +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +internal sealed class ParticipantEntry(val value: String) { + + class WithParticipant(name: String, val isProton: Boolean = false) : ParticipantEntry(name) + object NoSender : ParticipantEntry(getTestString(testR.string.test_mailbox_default_sender)) + object NoRecipient : ParticipantEntry(getTestString(testR.string.test_mailbox_default_recipient)) + + object Common { + + val ProtonOfficial = WithParticipant("Proton", isProton = true) + val ProtonUnofficial = WithParticipant("Proton", isProton = false) + val FreeUser = WithParticipant("sleepykoala@proton.black") + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt new file mode 100644 index 0000000000..7fe41054b3 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/mailbox/ParticipantEntryModel.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.mailbox + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags +import ch.protonmail.android.mailmailbox.presentation.mailbox.ParticipantsListTestTags +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.children +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +internal class ParticipantEntryModel( + val index: Int, + val parent: SemanticsNodeInteraction +) { + + private val participantRow: SemanticsNodeInteraction by lazy { + parent.children { hasTestTag(ParticipantsListTestTags.ParticipantRow) }[index] + } + + private val participant: SemanticsNodeInteraction by lazy { + participantRow.child { hasTestTag(ParticipantsListTestTags.Participant) } + } + + private val badge: SemanticsNodeInteraction by lazy { + participantRow.child { hasTestTag(OfficialBadgeTestTags.Item) } + } + + // If the model has no participants, the field is a direct child of the parent (has no row as ancestor). + private val noParticipant: SemanticsNodeInteraction by lazy { + parent.child { hasTestTag(ParticipantsListTestTags.NoParticipant) } + } + + fun hasParticipant(value: String) = apply { + participant.assertTextEquals(value) + noParticipant.assertDoesNotExist() + } + + fun hasNoParticipant(value: String) { + noParticipant.assertTextEquals(value) + participantRow.assertDoesNotExist() + } + + fun isProton(value: Boolean) { + if (value) { + badge.assertIsDisplayed() + badge.assertTextEquals(getTestString(testR.string.test_auth_badge_official)) + } else { + badge.assertDoesNotExist() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt new file mode 100644 index 0000000000..8cfb5aadaf --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarEntry.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.snackbar + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +internal abstract class SnackbarEntry( + val value: String, + val type: SnackbarType, + val timeout: Duration = DefaultDuration +) { + companion object { + val DefaultDuration = 15.seconds + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt new file mode 100644 index 0000000000..db5c8cfe1b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/models/snackbar/SnackbarType.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.models.snackbar + +internal enum class SnackbarType { + Default, + Normal, + Error, + Warning, + Success +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt new file mode 100644 index 0000000000..87bc0d06f7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeRobot.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot + +import androidx.compose.ui.test.junit4.ComposeTestRule +import ch.protonmail.android.test.robot.ProtonMailRobot +import ch.protonmail.android.test.utils.ComposeTestRuleHolder + +/** + * A base [ProtonMailRobot] for Compose screens. + */ +internal abstract class ComposeRobot( + val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) : ProtonMailRobot diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt new file mode 100644 index 0000000000..f2e5731aa7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/ComposeSectionRobot.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot + +import androidx.compose.ui.test.junit4.ComposeTestRule +import ch.protonmail.android.test.robot.ProtonMailSectionRobot +import ch.protonmail.android.test.utils.ComposeTestRuleHolder + +/** + * A base [ProtonMailSectionRobot] for Compose screens. + */ +internal abstract class ComposeSectionRobot( + val composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) : ProtonMailSectionRobot diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt new file mode 100644 index 0000000000..3019823436 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/SignOutAccountDialogRobot.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.account + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.feature.account.SignOutAccountDialogTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.awaitHidden + +@AsDsl +internal class SignOutAccountDialogRobot : ComposeRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(SignOutAccountDialogTestTags.RootItem) + + @VerifiesOuter + inner class Verify { + + fun isNotShown() { + rootItem.awaitHidden() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt new file mode 100644 index 0000000000..4982964051 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/account/section/SignOutAccountDialogButtonsSection.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.account.section + +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.performClick +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.account.SignOutAccountDialogRobot +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [SignOutAccountDialogRobot::class], identifier = "buttonsSection") +internal class SignOutAccountDialogButtonsSection : ComposeSectionRobot() { + + private val signOutButton = composeTestRule.onAllNodesWithText( + getTestString(testR.string.test_sign_out_dialog_confirm) + ).onLast() + + private val noButton = composeTestRule.onAllNodesWithText( + getTestString(testR.string.test_sign_out_dialog_cancel) + ).onLast() + + fun tapSignOut() { + signOutButton.performClick() + } + + fun tapCancel() { + noButton.performClick() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt new file mode 100644 index 0000000000..62f18ad96e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/bottombar/BottomBarSection.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.bottombar + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBarTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.bottombar.BottomBarActionEntry +import ch.protonmail.android.uitest.models.bottombar.BottomBarActionEntryModel +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot + +@AttachTo(targets = [MailboxRobot::class]) +internal class BottomBarSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(BottomActionBarTestTags.RootItem) + + fun tapAction(entry: BottomBarActionEntry) = onActionEntryModel(entry.index) { + click() + } + + @VerifiesOuter + inner class Verify { + + fun isShown() { + rootItem.assertIsDisplayed() + } + + fun isNotShown() { + rootItem.assertDoesNotExist() + } + + fun hasActions(vararg entries: BottomBarActionEntry) { + entries.forEach { + onActionEntryModel(it.index) { + hasDescription(it.description) + } + } + } + } + + private fun onActionEntryModel(position: Int, block: BottomBarActionEntryModel.() -> Unit) { + block(BottomBarActionEntryModel(position, rootItem)) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt new file mode 100644 index 0000000000..d1e9e32681 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/BottomActionBarRobot.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.contentDescriptionRes +import ch.protonmail.android.mailcommon.presentation.model.descriptionRes +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.onNodeWithContentDescription +import ch.protonmail.android.uitest.util.onNodeWithText +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG + +internal class BottomActionBarRobot : ComposeRobot() { + + @VerifiesOuter + inner class Verify { + + fun loaderIsDisplayed() { + onLoaderNode().assertIsDisplayed() + } + + fun failedLoadingErrorIsDisplayed() { + onErrorMessageNode().assertIsDisplayed() + } + + fun errorAndLoaderHidden() { + onLoaderNode().assertDoesNotExist() + onErrorMessageNode().assertDoesNotExist() + } + + fun actionIsDisplayed(action: Action) { + composeTestRule.onNodeWithContentDescription(action.descriptionRes) + .assertIsDisplayed() + } + + fun actionIsNotDisplayed(action: Action) { + composeTestRule.onNodeWithContentDescription(action.contentDescriptionRes) + .assertDoesNotExist() + } + + private fun onErrorMessageNode() = composeTestRule.onNodeWithText(R.string.common_error_loading_actions) + + private fun onLoaderNode() = composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG, useUnmergedTree = true) + } +} + +internal fun ComposeContentTestRule.BottomActionBarRobot(content: @Composable () -> Unit): BottomActionBarRobot { + setContent(content) + return BottomActionBarRobot() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt new file mode 100644 index 0000000000..e096547387 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/FullscreenLoaderSection.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.common.section + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.util.awaitHidden +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG + +@AttachTo(targets = [ComposerRobot::class]) +internal class FullscreenLoaderSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG) + + fun waitUntilGone() { + rootItem.awaitHidden() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt new file mode 100644 index 0000000000..7a20f250ca --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/KeyboardSection.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.common.section + +import androidx.test.espresso.Espresso +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.test.robot.ProtonMailSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice + +@AttachTo(targets = [ComposerRobot::class]) +internal class KeyboardSection : ProtonMailSectionRobot { + + fun dismissKeyboard() = apply { + Espresso.closeSoftKeyboard() + } + + @VerifiesOuter + inner class Verify { + + fun keyboardIsShown() = assertKeyboardShown(true) + + fun keyboardIsNotShown() = assertKeyboardShown(false) + + private fun assertKeyboardShown(expectedValue: Boolean) = apply { + assert( + uiDevice + .executeShellCommand("dumpsys input_method | grep mInputShown") + .contains("mInputShown=$expectedValue") + ) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt new file mode 100644 index 0000000000..a14b261233 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/common/section/SnackbarSection.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.common.section + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry +import ch.protonmail.android.uitest.models.snackbar.SnackbarType +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.assertions.hasAnyChildWith +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden + +@AttachTo( + targets = [ + ComposerRobot::class, + ConversationDetailRobot::class, + MessageDetailRobot::class, + MailboxRobot::class + ] +) +internal class SnackbarSection : ComposeSectionRobot() { + + // There are different hosts, thus they're defined as lazy to avoid + // spending unnecessary time locating unnecessary nodes. + private val snackbarHostDefault: SemanticsNodeInteraction by lazy { + composeTestRule.onAllNodesWithTag(CommonTestTags.SnackbarHost).onFirst() + } + + private val snackbarHostError: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostError, useUnmergedTree = true) + } + + private val snackbarHostWarning: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostWarning) + } + + private val snackbarHostNormal: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostNormal) + } + + private val snackbarHostSuccess: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(CommonTestTags.SnackbarHostSuccess) + } + + fun waitUntilDismisses(entry: SnackbarEntry) { + val host = resolveHost(entry) + host.awaitHidden() + } + + @VerifiesOuter + inner class Verify { + + fun isDisplaying(entry: SnackbarEntry) = apply { + val host = resolveHost(entry) + + // The actual text node is not an immediate child, so the hierarchy needs to be traversed. + host.awaitDisplayed(timeout = entry.timeout) + .hasAnyChildWith(hasText(entry.value)) + } + } + + private fun resolveHost(entry: SnackbarEntry): SemanticsNodeInteraction { + return when (entry.type) { + SnackbarType.Default -> snackbarHostDefault + SnackbarType.Normal -> snackbarHostNormal + SnackbarType.Error -> snackbarHostError + SnackbarType.Warning -> snackbarHostWarning + SnackbarType.Success -> snackbarHostSuccess + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt new file mode 100644 index 0000000000..25f9d6a1de --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/ComposerRobot.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AsDsl +internal class ComposerRobot : ComposeRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(ComposerTestTags.RootItem) + + init { + rootItem.awaitDisplayed() + } + + @VerifiesOuter + inner class Verify { + + fun composerIsShown() = apply { + rootItem.assertExists() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt new file mode 100644 index 0000000000..1be80159a4 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldEntryModels.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model + +import androidx.compose.ui.test.hasTestTag +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.Bcc +import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.Cc +import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes.To + +internal object ToRecipientEntryModel : ComposerRecipientsEntryModel( + parentMatcher = hasTestTag(ComposerTestTags.ToRecipient), + prefix = To +) + +internal object CcRecipientEntryModel : ComposerRecipientsEntryModel( + parentMatcher = hasTestTag(ComposerTestTags.CcRecipient), + prefix = Cc +) + +internal object BccRecipientEntryModel : ComposerRecipientsEntryModel( + parentMatcher = hasTestTag(ComposerTestTags.BccRecipient), + prefix = Bcc +) + diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt new file mode 100644 index 0000000000..ad7153156e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerFieldPrefixes.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model + +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +internal object ComposerFieldPrefixes { + + val From = Prefix(getTestString(testR.string.test_composer_sender_label)) + val To = Prefix(getTestString(testR.string.test_composer_to_recipient_label)) + val Cc = Prefix(getTestString(testR.string.test_composer_cc_recipient_label)) + val Bcc = Prefix(getTestString(testR.string.test_composer_bcc_recipient_label)) +} + +@JvmInline +internal value class Prefix(val value: String) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt new file mode 100644 index 0000000000..8eb3ba6a4a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/ComposerRecipientsEntryModel.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uicomponents.chips.ChipsTestTags +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntryModel +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipValidationState.Invalid +import ch.protonmail.android.uitest.util.assertions.assertEmptyText +import ch.protonmail.android.uitest.util.awaitHidden + +internal sealed class ComposerRecipientsEntryModel( + private val parentMatcher: SemanticsMatcher, + private val prefix: Prefix, + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) { + + private val parent = composeTestRule.onNode(parentMatcher, useUnmergedTree = true) + + private val prefixField = composeTestRule.onNode( + matcher = hasTestTag(ComposerTestTags.FieldPrefix) and hasAnyAncestor(parentMatcher), + useUnmergedTree = true + ) + + private val textField = composeTestRule.onNode( + // use hasAnyAncestor as the TextField is not a direct child of the parent. + matcher = hasTestTag(ChipsTestTags.BasicTextField) and hasAnyAncestor(parentMatcher) + ) + + // region actions + fun typeValue(value: String) = withParentFocused { + textField.performTextInput(value) + } + + @OptIn(ExperimentalTestApi::class) + fun tapKey(key: Key) { + textField.performKeyInput { this.pressKey(key) } + } + + fun performImeAction() { + textField.performImeAction() + } + + fun focus() { + parent.performClick() + } + + fun tapChipDeletionIconAt(position: Int) { + val model = RecipientChipEntryModel(position, parentMatcher) + model.tapDeleteIcon() + } + // endregion + + // region verification + fun isHidden() { + parent.awaitHidden().assertDoesNotExist() + } + + fun isFocused() = apply { + textField.assertIsFocused() + } + + fun hasEmptyValue() = withParentFocused { + hasPrefix() + textField.assertEmptyText() + + // Check that chip at index 0 does not exist, as recomposition will always populate index 0 if there's a chip. + RecipientChipEntryModel(0, parentMatcher).doesNotExist() + } + + fun hasValue(value: String) = withParentFocused { + hasPrefix() + textField.assertTextEquals(value) + } + + fun hasChips(vararg chips: RecipientChipEntry) { + for (chip in chips) { + val model = RecipientChipEntryModel(chip.index, parentMatcher) + model + .hasText(chip.text) + .hasEmailValidationState(chip.state) + .also { + if (chip.hasDeleteIcon) model.hasDeleteIcon() else model.hasNoDeleteIcon() + } + .also { + if (chip.state is Invalid) model.hasErrorIcon() else model.hasNoErrorIcon() + } + } + } + + fun hasNoChip(chip: RecipientChipEntry) { + val model = RecipientChipEntryModel(chip.index, parentMatcher) + model.doesNotExist() + } + + private fun hasPrefix() = apply { + prefixField.assertTextEquals(prefix.value) + } + // endregion + + // region utility methods + private fun withParentFocused(block: ComposerRecipientsEntryModel.() -> Unit) = apply { + // This is needed as even though the correct textField is located, the automation might fill the wrong field. + parent.performScrollTo() + + if (!peekIsFocused()) { + parent.performClick() + } + block() + } + + private fun peekIsFocused(): Boolean = runCatching { isFocused() }.isSuccess + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt new file mode 100644 index 0000000000..1fd4b2806f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/ChipsCreationTrigger.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.chips + +internal enum class ChipsCreationTrigger { + ImeAction, + NewLine +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt new file mode 100644 index 0000000000..82ee593f29 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntry.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.chips + +internal data class RecipientChipEntry( + val index: Int, + val text: String, + val hasDeleteIcon: Boolean = false, + val state: RecipientChipValidationState +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt new file mode 100644 index 0000000000..15e9269dec --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipEntryModel.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.chips + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uicomponents.chips.ChipsTestTags +import ch.protonmail.android.uitest.util.assertions.CustomSemanticsPropertyKeyNames +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.extensions.getKeyValueByName +import org.junit.Assert.assertEquals + +internal class RecipientChipEntryModel( + index: Int, + parentMatcher: SemanticsMatcher, + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) { + + private val parent = composeTestRule.onNode( + matcher = hasTestTag("${ChipsTestTags.InputChip}$index") and hasAnyAncestor(parentMatcher), + useUnmergedTree = true + ) + private val text = parent.child { hasTestTag(ChipsTestTags.InputChipText) } + private val errorIcon = parent.child { hasTestTag(ChipsTestTags.InputChipLeadingIcon) } + private val deleteIcon = parent.child { hasTestTag(ChipsTestTags.InputChipTrailingIcon) } + + // region actions + fun tapDeleteIcon() = withParentDisplayed { + deleteIcon.performScrollTo().performClick() + } + // endregion + + // region verification + fun hasText(value: String): RecipientChipEntryModel = withParentDisplayed { + text.assertTextEquals(value) + } + + fun hasEmailValidationState(state: RecipientChipValidationState) = withParentDisplayed { + parent.assertFieldState(state.value) + } + + fun hasDeleteIcon() = withParentDisplayed { + deleteIcon.performScrollTo().assertExists() + } + + fun hasNoDeleteIcon() = withParentDisplayed { + deleteIcon.assertDoesNotExist() + } + + fun hasErrorIcon() = withParentDisplayed { + errorIcon.assertExists() + } + + fun hasNoErrorIcon() = withParentDisplayed { + errorIcon.assertDoesNotExist() + } + + fun doesNotExist() { + parent.assertDoesNotExist() + } + // endregion + + // On some configurations, the transition from raw text to chip might take a bit and make the test fail. + private fun withParentDisplayed(block: RecipientChipEntryModel.() -> Unit) = apply { + parent.performScrollTo() + block() + } + + private fun SemanticsNodeInteraction.assertFieldState(isValid: Boolean) = apply { + val isValidProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.IsValidFieldKey)) { + "IsValidFieldKey property was not found on this node. Did you forget to set it?" + } + + assertEquals(isValid, isValidProperty.value) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt new file mode 100644 index 0000000000..dd2b915849 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/chips/RecipientChipValidationState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.chips + +internal sealed class RecipientChipValidationState(val value: Boolean) { + data object Valid : RecipientChipValidationState(true) + data object Invalid : RecipientChipValidationState(false) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt new file mode 100644 index 0000000000..a564e17d6a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntry.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.sender + +internal data class ChangeSenderEntry( + val index: Int, + val address: String, + val isEnabled: Boolean = true +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt new file mode 100644 index 0000000000..7af2614290 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/sender/ChangeSenderEntryModel.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.sender + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcomposer.presentation.ui.ChangeSenderBottomSheetTestTags +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden + +internal class ChangeSenderEntryModel(index: Int, composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule) { + + private val item = composeTestRule.onNodeWithTag("${ChangeSenderBottomSheetTestTags.Item}$index") + + // region action + fun selectSender() { + item.awaitDisplayed().performClick().awaitHidden() + } + // endregion + + // region verification + fun doesNotExist() { + item.assertDoesNotExist() + } + + fun hasText(value: String) { + item.awaitDisplayed().assertTextEquals(value) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt new file mode 100644 index 0000000000..d0b3444a68 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/model/snackbar/ComposerSnackbar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.model.snackbar + +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry +import ch.protonmail.android.uitest.models.snackbar.SnackbarType +import ch.protonmail.android.uitest.util.getTestString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +internal sealed class ComposerSnackbar( + value: String, + type: SnackbarType, + duration: Duration = DefaultDuration +) : SnackbarEntry(value, type, duration) { + + data object AttachmentUploadError : ComposerSnackbar( + getTestString(R.string.test_mailbox_attachment_uploading_error), SnackbarType.Error + ) + + data object DraftSaved : ComposerSnackbar( + getTestString(R.string.test_mailbox_draft_saved), SnackbarType.Success + ) + + data object DraftOutOfSync : ComposerSnackbar( + getTestString(R.string.test_composer_error_loading_draft), SnackbarType.Default + ) + + data object MessageSent : ComposerSnackbar( + getTestString(R.string.test_mailbox_message_sending_success), SnackbarType.Success, duration = SendingTimeout + ) + + data object MessageSentError : ComposerSnackbar( + getTestString(R.string.test_mailbox_message_sending_error), SnackbarType.Error, duration = SendingTimeout + ) + + data object MessageQueued : ComposerSnackbar( + getTestString(R.string.test_mailbox_message_sending_offline), SnackbarType.Normal + ) + + data object SendingMessage : ComposerSnackbar( + getTestString(R.string.test_mailbox_message_sending), SnackbarType.Normal + ) + + data object UpgradePlanToChangeSender : ComposerSnackbar( + getTestString(R.string.test_composer_change_sender_paid_feature), SnackbarType.Default + ) + + companion object { + // This is required as SendMessageWorker could be delayed by 30s due to pending UploadDraftWorker. + private val SendingTimeout = 60.seconds + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt new file mode 100644 index 0000000000..46bcac2c31 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ChangeSenderBottomSheetSection.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import ch.protonmail.android.mailcomposer.presentation.ui.ChangeSenderBottomSheetTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntry +import ch.protonmail.android.uitest.robot.composer.model.sender.ChangeSenderEntryModel +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden + +@AttachTo(targets = [ComposerRobot::class], identifier = "changeSenderBottomSheet") +internal class ChangeSenderBottomSheetSection : ComposeSectionRobot() { + + private val parent = composeTestRule.onNodeWithTag(ChangeSenderBottomSheetTestTags.Root) + + fun tapEntryAt(position: Int) { + val model = ChangeSenderEntryModel(position) + model.selectSender() + } + + fun dismiss() { + parent.performTouchInput { swipeDown() } + } + + @VerifiesOuter + inner class Verify { + + fun isShown() { + parent.awaitDisplayed().assertIsDisplayed() + } + + fun isHidden() { + parent.awaitHidden().assertIsNotDisplayed() + } + + fun hasEntries(vararg entries: ChangeSenderEntry) { + for (entry in entries) { + val model = ChangeSenderEntryModel(entry.index) + + if (entry.isEnabled) { + model.hasText(entry.address) + } else { + model.doesNotExist() + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt new file mode 100644 index 0000000000..7ef7a8b614 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerAlertDialogSection.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot + +@AttachTo(targets = [ComposerRobot::class], identifier = "composerAlertDialogSection") +internal class ComposerAlertDialogSection : ComposeSectionRobot() { + + private val sendWithEmptySubjectDialog = composeTestRule.onNodeWithTag( + ComposerTestTags.SendWithEmptySubjectDialog + ) + + private val sendWithEmptySubjectDialogDismissButton = composeTestRule.onNodeWithTag( + ComposerTestTags.SendWithEmptySubjectDialogDismiss + ) + private val sendWithEmptySubjectDialogConfirmButton = composeTestRule.onNodeWithTag( + ComposerTestTags.SendWithEmptySubjectDialogConfirm + ) + + fun clickSendWithEmptySubjectDialogDismissButton() = sendWithEmptySubjectDialogDismissButton.performClick() + fun clickSendWithEmptySubjectDialogConfirmButton() = sendWithEmptySubjectDialogConfirmButton.performClick() + + @VerifiesOuter + inner class Verify { + + fun isSendWithEmptySubjectDialogDisplayed() = sendWithEmptySubjectDialog.assertIsDisplayed() + fun isSendWithEmptySubjectDialogDismissed() = sendWithEmptySubjectDialog.assertDoesNotExist() + + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt new file mode 100644 index 0000000000..7423552b6b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerMessageBodySection.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [ComposerRobot::class], identifier = "messageBodySection") +internal class ComposerMessageBodySection : ComposeSectionRobot() { + + private val messageBodyText = composeTestRule.onNodeWithTag( + testTag = ComposerTestTags.MessageBody, + useUnmergedTree = true + ) + + private val messageBodyPlaceholder = composeTestRule.onNodeWithTag( + testTag = ComposerTestTags.MessageBodyPlaceholder, + useUnmergedTree = true + ) + + fun typeMessageBody(value: String) = apply { + messageBodyText.performTextInput(value) + } + + @VerifiesOuter + inner class Verify { + + fun hasPlaceholderText() = apply { + messageBodyPlaceholder.assertTextEquals(getTestString(testR.string.test_composer_body_placeholder)) + } + + fun hasText(value: String) = apply { + messageBodyPlaceholder.assertDoesNotExist() + messageBodyText.assertTextEquals(value) + } + + fun hasFocus() = apply { + messageBodyText.assertIsFocused() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt new file mode 100644 index 0000000000..7587428655 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSenderSection.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.mailcomposer.presentation.ui.PrefixedEmailSelectorTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.ComposerFieldPrefixes +import ch.protonmail.android.uitest.util.assertions.assertEditableTextEquals +import ch.protonmail.android.uitest.util.assertions.assertNotEditableTextEquals +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [ComposerRobot::class], identifier = "senderSection") +internal class ComposerSenderSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(ComposerTestTags.ComposerForm) + private val parent = rootItem.child { hasTestTag(ComposerTestTags.FromSender) } + + private val text = parent.child { + hasTestTag(PrefixedEmailSelectorTestTags.TextField) + } + private val changeSenderButton = parent.child { + hasTestTag(ComposerTestTags.ChangeSenderButton) + } + + fun tapChangeSender() { + changeSenderButton.performScrollTo().performClick() + } + + @VerifiesOuter + inner class Verify { + + fun hasValue(value: String) = text.performScrollTo().run { + assertNotEditableTextEquals(ComposerFieldPrefixes.From.value) + assertEditableTextEquals(value) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt new file mode 100644 index 0000000000..623cf5ec44 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerSubjectSection.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.util.assertions.assertEmptyText +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [ComposerRobot::class], identifier = "subjectSection") +internal class ComposerSubjectSection : ComposeSectionRobot() { + + private val subject = composeTestRule.onNodeWithTag(ComposerTestTags.Subject, useUnmergedTree = true) + private val subjectPlaceholder = composeTestRule.onNodeWithTag( + testTag = ComposerTestTags.SubjectPlaceholder, + useUnmergedTree = true + ) + + fun focusField() = withSubjectDisplayed { + subject.performClick() + } + + fun typeSubject(value: String) = withSubjectDisplayed { + subject.performTextInput(value) + } + + fun clearField() = withSubjectDisplayed { + subject.performTextClearance() + } + + fun performImeAction() = withSubjectDisplayed { + subject.performImeAction() + } + + private fun withSubjectDisplayed(block: ComposerSubjectSection.() -> Unit) = apply { + subject.performScrollTo() + block() + } + + @VerifiesOuter + inner class Verify { + + fun hasEmptySubject() { + subject.assertEmptyText() + subjectPlaceholder.assertTextEquals(SubjectPlaceholder) + } + + fun hasSubject(value: String) { + subject.assertTextEquals(value) + subjectPlaceholder.assertDoesNotExist() + } + + fun hasFocus() { + subject.assertIsFocused() + } + } + + private companion object { + + val SubjectPlaceholder = getTestString(testR.string.test_composer_subject_placeholder) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt new file mode 100644 index 0000000000..8158b5a720 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/ComposerTopBarAppSection.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.child + +@AttachTo( + targets = [ + ComposerRobot::class + ], + identifier = "topAppBarSection" +) +internal class ComposerTopBarAppSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag( + ComposerTestTags.TopAppBar + ) + + private val closeButton = rootItem.child { + hasTestTag(ComposerTestTags.CloseButton) + } + + private val attachmentsButton = rootItem.child { + hasTestTag(ComposerTestTags.AttachmentsButton) + } + + private val sendButton = rootItem.child { + hasTestTag(ComposerTestTags.SendButton) + } + + fun tapCloseButton() = apply { + closeButton.performClick() + rootItem.awaitHidden() + } + + fun tapAttachmentsButton() = apply { + attachmentsButton.performClick() + } + + fun tapSendButton() = apply { + sendButton.performClick() + } + + @VerifiesOuter + inner class Verify { + + fun isSendButtonEnabled() = apply { + sendButton.assertIsEnabled() + } + + fun isSendButtonDisabled() = apply { + sendButton.assertIsNotEnabled() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt new file mode 100644 index 0000000000..41a50761dd --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsBccSection.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section.recipients + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.BccRecipientEntryModel + +@AttachTo(targets = [ComposerRobot::class], identifier = "bccRecipientSection") +internal class ComposerRecipientsBccSection : ComposerRecipientsSection( + entryModel = BccRecipientEntryModel +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt new file mode 100644 index 0000000000..3b964a4294 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsCcSection.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section.recipients + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.CcRecipientEntryModel + +@AttachTo(targets = [ComposerRobot::class], identifier = "ccRecipientSection") +internal class ComposerRecipientsCcSection : ComposerRecipientsSection( + entryModel = CcRecipientEntryModel +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt new file mode 100644 index 0000000000..1a8ca4fbe4 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsSection.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section.recipients + +import android.view.KeyEvent +import androidx.compose.ui.input.key.Key +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.model.ComposerRecipientsEntryModel +import ch.protonmail.android.uitest.robot.composer.model.chips.ChipsCreationTrigger +import ch.protonmail.android.uitest.robot.composer.model.chips.RecipientChipEntry + +internal abstract class ComposerRecipientsSection( + private val entryModel: ComposerRecipientsEntryModel +) : ComposeSectionRobot() { + + fun focusField() { + entryModel.focus() + } + + fun typeRecipient(value: String, autoConfirm: Boolean = false) = apply { + entryModel.typeValue(value) + if (autoConfirm) triggerChipCreation(ChipsCreationTrigger.NewLine) + } + + fun typeMultipleRecipients(vararg values: String) { + values.forEach { + typeRecipient(it) + triggerChipCreation(ChipsCreationTrigger.NewLine) + } + } + + fun tapRecipientField() = apply { + entryModel.focus() + } + + fun triggerChipCreation(trigger: ChipsCreationTrigger = ChipsCreationTrigger.ImeAction) = apply { + when (trigger) { + ChipsCreationTrigger.ImeAction -> tapImeAction() + ChipsCreationTrigger.NewLine -> typeNewLine() + } + } + + fun deleteChipAt(position: Int) = apply { + entryModel.tapChipDeletionIconAt(position) + } + + fun tapBackspace() = apply { + entryModel.tapKey(Key.Backspace) + } + + private fun typeNewLine() = apply { + entryModel.tapKey(Key(KeyEvent.KEYCODE_ENTER)) + } + + private fun tapImeAction() = apply { + entryModel.performImeAction() + } + + @VerifiesOuter + inner class Verify { + + fun isFieldFocused() = apply { + entryModel.isFocused() + } + + fun isHidden() = apply { + entryModel.isHidden() + } + + fun isEmptyField() = apply { + entryModel.hasEmptyValue() + } + + fun hasRecipient(value: String) = apply { + entryModel.hasValue(value) + } + + fun hasRecipientChips(vararg chips: RecipientChipEntry) = apply { + entryModel.hasChips(*chips) + } + + fun recipientChipIsNotDisplayed(chip: RecipientChipEntry) = apply { + entryModel.hasNoChip(chip) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt new file mode 100644 index 0000000000..77e258ee74 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/composer/section/recipients/ComposerRecipientsToSection.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.composer.section.recipients + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.composer.model.ToRecipientEntryModel + +@AttachTo(targets = [ComposerRobot::class], identifier = "toRecipientSection") +internal class ComposerRecipientsToSection : ComposerRecipientsSection( + entryModel = ToRecipientEntryModel +) { + + private val expandRecipientsButton = composeTestRule.onNode( + matcher = hasTestTag(ComposerTestTags.ExpandCollapseArrow), + useUnmergedTree = true + ) + + private val hideRecipientsButton = composeTestRule.onNode( + matcher = hasTestTag(ComposerTestTags.CollapseExpandArrow), + useUnmergedTree = true + ) + + fun chevronNotVisible() = apply { + expandRecipientsButton.assertDoesNotExist() + } + + fun expandCcAndBccFields() = apply { + expandRecipientsButton.performScrollTo().performClick() + } + + fun hideCcAndBccFields() = apply { + hideRecipientsButton.performScrollTo().performClick() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt new file mode 100644 index 0000000000..bfd7ac1611 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/ConversationDetailRobot.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AsDsl +internal class ConversationDetailRobot : ComposeRobot() { + + @VerifiesOuter + inner class Verify : ComposeSectionRobot() { + + fun conversationDetailScreenIsShown() { + composeTestRule.onNodeWithTag(ConversationDetailScreenTestTags.RootItem) + .awaitDisplayed() + .assertExists() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt new file mode 100644 index 0000000000..b2c9caf006 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/MessageDetailRobot.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AsDsl +internal class MessageDetailRobot : ComposeRobot() { + + @VerifiesOuter + inner class Verify { + + fun messageDetailScreenIsShown() { + composeTestRule.onNodeWithTag(MessageDetailScreenTestTags.RootItem) + .awaitDisplayed() + .assertExists() + } + } +} + +internal fun ComposeContentTestRule.MessageDetailRobot(content: @Composable () -> Unit): MessageDetailRobot { + setContent(content) + return MessageDetailRobot() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt new file mode 100644 index 0000000000..e69cffb22f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/DetailScreenTopBarEntryModel.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.maildetail.presentation.ui.DetailScreenTopBarTestTags +import ch.protonmail.android.uitest.util.child + +internal class DetailScreenTopBarEntryModel(composeTestRule: ComposeTestRule) { + + private val rootItem = composeTestRule + .onNodeWithTag( + testTag = DetailScreenTopBarTestTags.RootItem, + useUnmergedTree = true + ) + + private val backButton = composeTestRule.onNodeWithTag( + testTag = DetailScreenTopBarTestTags.BackButton, + useUnmergedTree = true + ) + + private val subject = rootItem.child { + hasTestTag(DetailScreenTopBarTestTags.Subject) + } + + // region actions + fun tapBack() { + backButton.performClick() + } + // endregion + + // region verification + fun hasSubject(value: String) = apply { + subject.onChild().assertTextEquals(value) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt new file mode 100644 index 0000000000..7d217618a6 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/MessageDetailSnackbar.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model + +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry +import ch.protonmail.android.uitest.models.snackbar.SnackbarType +import ch.protonmail.android.uitest.util.getTestString + +internal sealed class MessageDetailSnackbar(value: String, type: SnackbarType) : SnackbarEntry(value, type) { + + class ConversationMovedToFolder(folder: String) : MessageDetailSnackbar( + getTestString(R.string.test_conversation_moved_to_selected_destination, folder), SnackbarType.Normal + ) + + object FailedToDecryptMessage : MessageDetailSnackbar( + getTestString(R.string.test_decryption_error), SnackbarType.Default + ) + + object FailedToGetAttachment : MessageDetailSnackbar( + getTestString(R.string.error_get_attachment_failed), SnackbarType.Default + ) + + object FailedToLoadMessage : MessageDetailSnackbar( + getTestString(R.string.test_detail_error_retrieving_message_body), SnackbarType.Default + ) + + object MultipleDownloadsWarning : MessageDetailSnackbar( + getTestString(R.string.test_error_attachment_download_in_progress), SnackbarType.Default + ) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt new file mode 100644 index 0000000000..10cd426018 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntry.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.attachments + +internal data class AttachmentDetailItemEntry( + val index: Int, + val fileName: String, + val fileSize: String, + val hasDeleteIcon: Boolean = false +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt new file mode 100644 index 0000000000..e21594a05e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailItemEntryModel.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.attachments + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooterTestTags +import ch.protonmail.android.mailmessage.presentation.ui.AttachmentItemTestTags +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.child + +internal class AttachmentDetailItemEntryModel(index: Int, parent: SemanticsNodeInteraction) { + + private val item = parent.child { + hasTestTag("${AttachmentFooterTestTags.Item}$index") + } + + private val icon = item.child { + hasTestTag(AttachmentItemTestTags.Icon) + } + + private val loader = item.child { + hasTestTag(AttachmentItemTestTags.Loader) + } + + private val name = item.child { + hasTestTag(AttachmentItemTestTags.Name) + } + + private val extension = item.child { + hasTestTag(AttachmentItemTestTags.Extension) + } + + private val deleteIcon = item.child { + hasTestTag(AttachmentItemTestTags.Delete) + } + + private val size = item.child { + hasTestTag(AttachmentItemTestTags.Size) + } + + // region actions + fun tapItem() { + name.performClick() + } + // endregion + + // region verification + fun hasIcon(): AttachmentDetailItemEntryModel = apply { + icon.assertIsDisplayed() + loader.assertDoesNotExist() + } + + fun hasLoaderIcon(): AttachmentDetailItemEntryModel = apply { + icon.awaitHidden().assertDoesNotExist() + loader.awaitDisplayed().assertIsDisplayed() + } + + fun hasNoLoaderIcon(): AttachmentDetailItemEntryModel = apply { + loader.awaitHidden().assertDoesNotExist() + } + + fun hasName(value: String): AttachmentDetailItemEntryModel = apply { + val (fileName, fileExtension) = value.split(".") + name.assertTextEquals(fileName) + extension.assertTextEquals(".$fileExtension") + } + + fun hasDeleteIcon(): AttachmentDetailItemEntryModel = apply { + deleteIcon.assertIsDisplayed() + } + + fun hasNoDeleteIcon(): AttachmentDetailItemEntryModel = apply { + deleteIcon.assertDoesNotExist() + } + + fun hasSize(value: String): AttachmentDetailItemEntryModel = apply { + size.assertTextEquals(value) + } + // endregion + + // region utility + fun waitUntilShown() = apply { + item.awaitDisplayed() + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt new file mode 100644 index 0000000000..2187defd88 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/attachments/AttachmentDetailSummaryEntry.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.attachments + +internal data class AttachmentDetailSummaryEntry( + val summary: String, + val size: String +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt new file mode 100644 index 0000000000..47e1957c2b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntry.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.bottomsheet + +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +internal data class MoveToBottomSheetFolderEntry( + val index: Int, + val name: String, + val iconTint: Tint = Tint.NoColor, + val isSelected: Boolean = false +) { + + object SystemFolders { + + val Inbox = MoveToBottomSheetFolderEntry(index = 0, name = getTestString(testR.string.label_title_inbox)) + val Archive = MoveToBottomSheetFolderEntry(index = 1, name = getTestString(testR.string.label_title_archive)) + val Spam = MoveToBottomSheetFolderEntry(index = 2, name = getTestString(testR.string.label_title_spam)) + val Trash = MoveToBottomSheetFolderEntry(index = 3, name = getTestString(testR.string.label_title_trash)) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt new file mode 100644 index 0000000000..3624deb350 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/bottomsheet/MoveToBottomSheetFolderEntryModel.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.bottomsheet + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.MoveToBottomSheetTestTags +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.util.assertions.assertTintColor +import ch.protonmail.android.uitest.util.child + +internal class MoveToBottomSheetFolderEntryModel private constructor( + matcher: SemanticsMatcher, + index: Int, + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule +) { + + private val rootItem = composeTestRule.onAllNodes(matcher, useUnmergedTree = true)[index] + + private val icon = rootItem.child { + hasTestTag(MoveToBottomSheetTestTags.FolderIcon) + } + + private val text = rootItem.child { + hasTestTag(MoveToBottomSheetTestTags.FolderNameText) + } + + private val selectionIcon = rootItem.child { + hasTestTag(MoveToBottomSheetTestTags.FolderSelectionIcon) + } + + // region actions + fun click() { + rootItem.performClick() + } + // endregion + + // region verification + fun hasIcon(tint: Tint): MoveToBottomSheetFolderEntryModel = apply { + icon.assertExists() + icon.assertTintColor(tint) + } + + fun hasText(value: String): MoveToBottomSheetFolderEntryModel = apply { + text.assertTextEquals(value) + } + + fun hasSelectionIcon(): MoveToBottomSheetFolderEntryModel = apply { + selectionIcon.assertExists() + } + + fun hasNoSelectionIcon(): MoveToBottomSheetFolderEntryModel = apply { + selectionIcon.assertDoesNotExist() + } + // endregion + + companion object { + + operator fun invoke(index: Int): MoveToBottomSheetFolderEntryModel { + val matcher = hasTestTag(MoveToBottomSheetTestTags.FolderItem) + return MoveToBottomSheetFolderEntryModel(matcher, index) + } + + operator fun invoke(folderName: String): MoveToBottomSheetFolderEntryModel { + val matcher = hasText(folderName) and hasParent(hasTestTag(MoveToBottomSheetTestTags.FolderItem)) + return MoveToBottomSheetFolderEntryModel(matcher, index = 0) // Always 0, no duplicates in names. + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt new file mode 100644 index 0000000000..892dac2695 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageBannerEntryModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.conversation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child + +internal class MessageBannerEntryModel(composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule) { + + private val rootItem = composeTestRule.onNodeWithTag(MessageBodyTestTags.MessageBodyBanner) + + private val icon = rootItem.child { + hasTestTag(MessageBodyTestTags.MessageBodyBannerIcon) + } + + private val text = rootItem.child { + hasTestTag(MessageBodyTestTags.MessageBodyBannerText) + } + + // region verification + fun doesNotExist() { + rootItem.assertDoesNotExist() + } + + fun isDisplayedWithText(value: String) { + rootItem.awaitDisplayed() + icon.assertIsDisplayed() + text.assertTextEquals(value) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt new file mode 100644 index 0000000000..719a8f42c4 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/model/conversation/MessageCollapsedItemEntryModel.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.model.conversation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcommon.presentation.compose.AvatarTestTags +import ch.protonmail.android.mailcommon.presentation.compose.OfficialBadgeTestTags +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailCollapsedMessageHeaderTestTags +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString + +@Suppress("TooManyFunctions") +internal data class MessageCollapsedItemEntryModel( + private val index: Int, + private val composeTestRule: ComposeTestRule +) { + + private val rootItem = composeTestRule.onAllNodesWithTag( + testTag = ConversationDetailCollapsedMessageHeaderTestTags.RootItem, + useUnmergedTree = true + )[index] + + private val avatarRootItem = rootItem.child { + hasTestTag(AvatarTestTags.AvatarRootItem) + } + + private val avatar = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarText) + } + + private val avatarDraft = avatarRootItem.child { + hasTestTag(AvatarTestTags.AvatarDraft) + } + + private val attachmentIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.AttachmentIcon) + } + + private val repliedIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.RepliedIcon) + } + + private val repliedAllIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.RepliedAllIcon) + } + + private val forwardedIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ForwardedIcon) + } + + private val starIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.StarIcon) + } + + private val sender = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Sender) + } + + private val authenticityBadge = rootItem.child { + hasTestTag(OfficialBadgeTestTags.Item) + } + + private val expirationIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ExpirationIcon) + } + + private val expirationText = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.ExpirationText) + } + + private val locationIcon = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Location) + } + + private val time = rootItem.child { + hasTestTag(ConversationDetailCollapsedMessageHeaderTestTags.Time) + } + + // region actions + fun click(): MessageCollapsedItemEntryModel = apply { + rootItem.performClick() + } + // endregion + + // region verification + fun isNotDisplayed(): MessageCollapsedItemEntryModel = apply { + rootItem.assertDoesNotExist() + } + + fun hasAvatar(initial: AvatarInitial): MessageCollapsedItemEntryModel = apply { + when (initial) { + is AvatarInitial.WithText -> avatar.assertTextEquals(initial.text) + is AvatarInitial.Draft -> avatarDraft.assertIsDisplayed() + } + } + + fun hasAttachmentIcon(): MessageCollapsedItemEntryModel = apply { + attachmentIcon.assertIsDisplayed() + } + + fun hasRepliedIcon(): MessageCollapsedItemEntryModel = apply { + repliedIcon.assertIsDisplayed() + } + + fun hasRepliedAllIcon(): MessageCollapsedItemEntryModel = apply { + repliedAllIcon.assertIsDisplayed() + } + + fun hasForwardedIcon(): MessageCollapsedItemEntryModel = apply { + forwardedIcon.assertIsDisplayed() + } + + fun hasStarIcon(): MessageCollapsedItemEntryModel = apply { + starIcon.assertIsDisplayed() + } + + fun hasSender(value: String): MessageCollapsedItemEntryModel = apply { + sender.assertTextEquals(value) + } + + fun hasAuthenticityBadge(expectedValue: Boolean): MessageCollapsedItemEntryModel = apply { + if (expectedValue) { + authenticityBadge.assertIsDisplayed() + authenticityBadge.assertTextEquals(getTestString(R.string.test_auth_badge_official)) + } else { + authenticityBadge.assertDoesNotExist() + } + } + + fun hasExpiration(value: String): MessageCollapsedItemEntryModel = apply { + expirationIcon.assertIsDisplayed() + expirationText.assertTextEquals(value) + } + + fun hasLocationIcon(): MessageCollapsedItemEntryModel = apply { + locationIcon.assertIsDisplayed() + } + + fun hasTime(value: String): MessageCollapsedItemEntryModel = apply { + time.assertTextEquals(value) + } + // endregion +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt new file mode 100644 index 0000000000..72109eb55c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailBottomBarSection.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.maildetail.presentation.R +import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.LabelAsBottomSheetTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.onNodeWithContentDescription + +@AttachTo( + targets = [ + ConversationDetailRobot::class, + MessageDetailRobot::class + ], + identifier = "bottomBarSection" +) +internal class DetailBottomBarSection : ComposeSectionRobot() { + + fun markAsUnread() = apply { + composeTestRule.onNodeWithContentDescription(R.string.action_mark_unread_content_description).performClick() + } + + fun moveToTrash() = apply { + composeTestRule.onNodeWithContentDescription(R.string.action_trash_content_description).performClick() + } + + fun openMoveToBottomSheet() = apply { + composeTestRule.onNodeWithContentDescription(R.string.action_move_content_description).performClick() + } + + fun openLabelAsBottomSheet() = apply { + composeTestRule.onNodeWithContentDescription(R.string.action_label_content_description).performClick() + } + + @VerifiesOuter + inner class Verify { + fun labelAsBottomSheetExists() { + composeTestRule.onNodeWithTag(LabelAsBottomSheetTestTags.RootItem, useUnmergedTree = true) + .awaitDisplayed() + .assertExists() + } + + fun labelAsBottomSheetIsDismissed() { + composeTestRule.onNodeWithTag(LabelAsBottomSheetTestTags.RootItem, useUnmergedTree = true) + .awaitHidden() + .assertIsNotDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt new file mode 100644 index 0000000000..4bd2aab10e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/DetailTopBarSection.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.DetailScreenTopBarEntryModel + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class]) +internal class DetailTopBarSection : ComposeSectionRobot() { + + private val topBarDetailModel = DetailScreenTopBarEntryModel(composeTestRule) + + fun tapBackButton() { + topBarDetailModel.tapBack() + } + + @VerifiesOuter + inner class Verify { + + fun hasSubject(subject: String) = apply { + topBarDetailModel.hasSubject(subject) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt new file mode 100644 index 0000000000..5ec9cb3cc6 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageActionsSection.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class], identifier = "actionsSection") +internal class MessageActionsSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(MessageBodyTestTags.MessageActionsRootItem) + + private val replyButton = rootItem.child { + hasTestTag(MessageBodyTestTags.MessageReplyButton) + } + + private val replyAllButton = rootItem.child { + hasTestTag(MessageBodyTestTags.MessageReplyAllButton) + } + + private val forwardButton = rootItem.child { + hasTestTag(MessageBodyTestTags.MessageForwardButton) + } + + fun tapReplyButton() { + replyButton.performScrollTo().performClick() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt new file mode 100644 index 0000000000..8646502a97 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBannerSection.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.conversation.MessageBannerEntryModel +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class], identifier = "bannerSection") +internal class MessageBannerSection : ComposeSectionRobot() { + + private val messageBannerEntryModel = MessageBannerEntryModel() + + @VerifiesOuter + inner class Verify { + + fun hasBlockedEmbeddedImagesBannerDisplayed() { + messageBannerEntryModel.isDisplayedWithText( + getTestString(testR.string.test_message_body_embedded_images_banner_text) + ) + } + + fun hasBlockedRemoteImagesBannerDisplayed() { + messageBannerEntryModel.isDisplayedWithText( + getTestString(testR.string.test_message_body_remote_content_banner_text) + ) + } + + fun hasBlockerEmbeddedAndRemoteImagesBannerDisplayed() { + messageBannerEntryModel.isDisplayedWithText( + getTestString(testR.string.test_message_body_embedded_and_remote_content_banner_text) + ) + } + + fun hasBlockedContentBannerNotDisplayed() { + messageBannerEntryModel.doesNotExist() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt new file mode 100644 index 0000000000..0f42b4cd26 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageBodySection.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.espresso.matcher.ViewMatchers.withClassName +import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches +import androidx.test.espresso.web.model.Atoms.castOrDie +import androidx.test.espresso.web.model.Atoms.script +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.sugar.Web.onWebView +import androidx.test.espresso.web.webdriver.DriverAtoms.findElement +import androidx.test.espresso.web.webdriver.DriverAtoms.getText +import androidx.test.espresso.web.webdriver.Locator +import ch.protonmail.android.maildetail.presentation.R +import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags +import ch.protonmail.android.mailmessage.presentation.ui.MessageBodyWebViewTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.onNodeWithText +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.core.Is.`is` +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class]) +internal class MessageBodySection : ComposeSectionRobot() { + + private val webView: Web.WebInteraction<*> by lazy { + onWebView(withClassName(equalTo(WebViewClassName))).apply { + forceJavascriptEnabled() + } + } + + private val webViewWrapper: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView) + } + + private val webViewAlternative: SemanticsNodeInteraction by lazy { + composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative) + } + + fun waitUntilMessageIsShown(timeout: Duration = 30.seconds) { + composeTestRule.waitForIdle() + + // Wait for the WebView to appear. + webViewWrapper.awaitDisplayed(timeout = timeout) + } + + @VerifiesOuter + internal inner class Verify { + + fun isShowingMissingWebViewWarning() { + webViewAlternative.awaitDisplayed() + webViewWrapper.assertDoesNotExist() + } + + fun messageInWebViewContains(messageBody: String, tagName: String = "html") { + webView.withElement(findElement(Locator.TAG_NAME, tagName)) + .check(webMatches(getText(), containsString(messageBody))) + webViewAlternative.assertDoesNotExist() + } + + fun loadingErrorMessageIsDisplayed(@StringRes errorMessage: Int) { + composeTestRule.onNodeWithText(errorMessage) + .assertIsDisplayed() + } + + fun loadingErrorMessageIsDisplayed(errorMessage: String) { + composeTestRule.onNodeWithText(errorMessage) + .assertIsDisplayed() + } + + fun bodyReloadButtonIsDisplayed() { + composeTestRule.onNodeWithText(R.string.reload) + .assertIsDisplayed() + } + + fun bodyDecryptionErrorMessageIsDisplayed() { + composeTestRule.onNodeWithText(R.string.decryption_error) + .assertIsDisplayed() + } + + fun hasRemoteImageLoaded(expected: Boolean) { + val jsSnippet = """ + |var image = document.querySelector('img'); + |var isLoaded = image.complete && image.naturalWidth != 0 && image.naturalHeight != 0; + |return isLoaded; + """.trimMargin() + + runAndMatchJsCodeOutput(jsSnippet, expected) + } + + fun hasHtmlContentSanitised() { + val jsSnippet = """ + |var onLoad = document.querySelector('body').onload; + |var form = document.querySelector('form'); + |var relLinks = document.querySelector('link'); + |var iframe = document.querySelector('iframe'); + |var ping = document.querySelector('a').ping; + |return onLoad == null && form == null && relLinks == null && iframe == null && ping == ""; + """.trimMargin() + + runAndMatchJsCodeOutput(jsSnippet, true) + } + + fun hasEmbeddedImages(expectedNumber: Int) { + val jsSnippet = """ + |var embeddedImages = Array.from(document.getElementsByClassName('proton-embedded')); + |return embeddedImages.length == $expectedNumber; + """.trimMargin() + + runAndMatchJsCodeOutput(jsSnippet, true) + } + + fun hasEmbeddedImagesSuccessfullyLoaded(expected: Boolean) { + val jsSnippet = """ + |var embeddedImages = Array.from(document.getElementsByClassName('proton-embedded')); + |if (embeddedImages.length == 0) { + | return false; + |} + |var imagesLoaded = embeddedImages.every(value => value.complete && value.naturalHeight != 0 && value.naturalWidth != 0); + |return imagesLoaded; + """.trimMargin() + + runAndMatchJsCodeOutput(jsSnippet, expected) + } + + private fun runAndMatchJsCodeOutput(script: String, expected: Boolean) { + webView.check( + webMatches( + script(script, castOrDie(Boolean::class.javaObjectType)), + `is`(equalTo(expected)) + ) + ) + } + } + + private companion object { + + const val WebViewClassName = "android.webkit.WebView" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt new file mode 100644 index 0000000000..215ec91221 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageExpandedHeaderSection.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.detail.ExtendedHeaderRecipientEntry +import ch.protonmail.android.uitest.models.detail.MessageHeaderExpandedEntryModel +import ch.protonmail.android.uitest.models.labels.LabelEntry +import ch.protonmail.android.uitest.robot.ComposeSectionRobot + +internal class MessageExpandedHeaderSection : ComposeSectionRobot() { + + private val expandedHeader = MessageHeaderExpandedEntryModel(composeTestRule) + + fun collapse() { + expandedHeader.collapse() + } + + @VerifiesOuter + internal inner class Verify { + + fun hasRecipients(vararg recipients: ExtendedHeaderRecipientEntry) { + expandedHeader.hasRecipients(*recipients) + } + + fun hasLabels(vararg labels: LabelEntry) { + expandedHeader.hasLabels(*labels) + } + + fun hasTime(value: String) { + expandedHeader.hasTime(value) + } + + fun hasLocation(value: String) { + expandedHeader.hasLocation(value) + } + + fun hasSize(value: String) { + expandedHeader.hasSize(value) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt new file mode 100644 index 0000000000..45ceaa1d2b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageFooterAttachmentSection.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollTo +import androidx.test.espresso.Espresso +import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooterTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.composer.ComposerRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntry +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailItemEntryModel +import ch.protonmail.android.uitest.robot.detail.model.attachments.AttachmentDetailSummaryEntry +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child + +@AttachTo( + targets = [ + ComposerRobot::class, + ConversationDetailRobot::class, + MessageDetailRobot::class + ], + identifier = "attachmentsSection" +) +internal class MessageFooterAttachmentSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag( + testTag = AttachmentFooterTestTags.Root, + useUnmergedTree = true + ) + + private val paperClipIcon = rootItem.child { + hasTestTag(AttachmentFooterTestTags.PaperClipIcon) + } + + private val summaryText = rootItem.child { + hasTestTag(AttachmentFooterTestTags.SummaryText) + } + + private val summarySize = rootItem.child { + hasTestTag(AttachmentFooterTestTags.SummarySize) + } + + init { + scrollTo() + // If the section is expanding in a conversation detail, tapping via interactors might fail. + composeTestRule.waitForIdle() + } + + fun tapItem(position: Int = 0) = withItemEntryModel(position) { + tapItem() + } + + private fun scrollTo() { + Espresso.closeSoftKeyboard() + rootItem.awaitDisplayed().performScrollTo() + } + + @VerifiesOuter + inner class Verify { + + fun hasLoaderDisplayedForItem(position: Int = 0) = withItemEntryModel(position) { + hasLoaderIcon() + } + + fun hasLoaderNotDisplayedForItem(position: Int = 0) = withItemEntryModel(position) { + hasNoLoaderIcon() + } + + fun hasSummaryDetails(details: AttachmentDetailSummaryEntry) { + paperClipIcon.assertIsDisplayed() + summaryText.assertTextEquals(details.summary) + summarySize.assertTextEquals(details.size) + } + + fun hasAttachments(vararg entries: AttachmentDetailItemEntry) { + for (entry in entries) { + withItemEntryModel(entry.index) { + waitUntilShown() + .hasIcon() + .hasName(entry.fileName) + .hasSize(entry.fileSize) + .also { + if (entry.hasDeleteIcon) it.hasDeleteIcon() else it.hasNoDeleteIcon() + } + } + } + } + } + + private fun withItemEntryModel(position: Int, block: AttachmentDetailItemEntryModel.() -> Unit) { + val model = AttachmentDetailItemEntryModel(position, rootItem) + block(model) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt new file mode 100644 index 0000000000..6da4b31089 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MessageHeaderSection.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.detail.MessageHeaderEntryModel +import ch.protonmail.android.uitest.models.labels.LabelEntry +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class]) +internal class MessageHeaderSection : ComposeSectionRobot() { + + private val headerModel = MessageHeaderEntryModel(composeTestRule) + + fun collapseMessage() = apply { + headerModel.collapseMessage() + } + + fun expandHeader() = apply { + headerModel.click() + } + + fun tapReplyButton() = apply { + headerModel.tapReplyButton() + } + + // The `expanded` subsection is an exception, but necessary to split the header checks reasonably. + fun expanded(block: MessageExpandedHeaderSection.() -> Unit) = MessageExpandedHeaderSection().apply(block) + + @VerifiesOuter + inner class Verify { + + private val headerModel = MessageHeaderEntryModel(composeTestRule) + + fun headerIsDisplayed() { + headerModel.isDisplayed() + } + + fun hasAvatarInitial(initial: AvatarInitial) { + headerModel.hasAvatar(initial) + } + + fun hasSenderName(sender: String) { + headerModel.hasSenderName(sender) + } + + fun hasSenderAddress(address: String) { + headerModel.hasSenderAddress(address) + } + + fun hasAuthenticityBadge(value: Boolean) { + headerModel.hasAuthenticityBadge(value) + } + + fun hasRecipient(value: String) { + headerModel.hasRecipient(value) + } + + fun hasTime(value: String) = apply { + headerModel.hasDate(value) + } + + fun hasLabels(vararg labels: LabelEntry) = apply { + headerModel.hasLabels(*labels) + } + + fun hasReplyButton() = apply { + headerModel.hasReplyButton() + } + + fun hasReplyAllButton() = apply { + headerModel.hasReplyAllButton() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt new file mode 100644 index 0000000000..45982588d8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/MoveToBottomSheetSection.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmessage.presentation.ui.bottomsheet.MoveToBottomSheetTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntry +import ch.protonmail.android.uitest.robot.detail.model.bottomsheet.MoveToBottomSheetFolderEntryModel +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitEnabled +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [ConversationDetailRobot::class, MessageDetailRobot::class]) +internal class MoveToBottomSheetSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag( + testTag = MoveToBottomSheetTestTags.RootItem, + useUnmergedTree = true + ) + + private val headerText = rootItem.child { + hasTestTag(MoveToBottomSheetTestTags.MoveToText) + } + + private val doneButton = rootItem.child { + hasTestTag(MoveToBottomSheetTestTags.DoneButton) + } + + fun tapDoneButton() { + doneButton.awaitEnabled().performClick() + } + + fun selectFolderAtPosition(index: Int) { + val model = MoveToBottomSheetFolderEntryModel(index) + + model.click() + } + + fun selectFolderWithName(name: String) { + val model = MoveToBottomSheetFolderEntryModel(folderName = name) + + model.click() + } + + fun dismiss() { + uiDevice.pressBack() + } + + @VerifiesOuter + inner class Verify { + + fun isShown() { + rootItem + .awaitDisplayed() + .assertExists() + } + + fun isHidden() { + rootItem + .awaitHidden() + .isNotDisplayed() + } + + fun headerTextIsShown() { + headerText.assertTextEquals(getTestString(testR.string.test_bottom_sheet_move_to_title)) + } + + fun doneButtonIsShown() { + doneButton.assertTextEquals(getTestString(testR.string.test_bottom_sheet_done_action)) + } + + fun hasFolders(vararg entries: MoveToBottomSheetFolderEntry) { + entries.forEach { + val entryModel = MoveToBottomSheetFolderEntryModel(it.index) + + entryModel + .hasIcon(it.iconTint) + .hasText(it.name) + .also { model -> if (it.isSelected) model.hasSelectionIcon() else model.hasNoSelectionIcon() } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt new file mode 100644 index 0000000000..4d8e2a11c7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/detail/section/conversation/ConversationDetailCollapsedMessagesSection.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.detail.section.conversation + +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.model.conversation.MessageCollapsedItemEntryModel + +@AttachTo(targets = [ConversationDetailRobot::class], identifier = "messagesCollapsedSection") +internal class ConversationDetailCollapsedMessagesSection : ComposeSectionRobot() { + + private val messagesList = composeTestRule.onNodeWithTag(ConversationDetailScreenTestTags.MessagesList) + + fun scrollToTop() { + messagesList.performTouchInput { swipeUp() } + } + + fun openMessageAtIndex(index: Int) = withEntryModel(index) { + click() + } + + @VerifiesOuter + inner class Verify { + + fun collapsedHeaderIsNotDisplayed() = withEntryModel(index = 0) { + isNotDisplayed() + } + + fun avatarInitialIsDisplayed(index: Int, text: String) = withEntryModel(index) { + hasAvatar(AvatarInitial.WithText(text)) + } + + fun avatarDraftIsDisplayed(index: Int) = withEntryModel(index) { + hasAvatar(AvatarInitial.Draft) + } + + fun attachmentIconIsDisplayed(index: Int) = withEntryModel(index) { + hasAttachmentIcon() + } + + fun forwardedIconIsDisplayed(index: Int) = withEntryModel(index) { + hasForwardedIcon() + } + + fun repliedAllIconIsDisplayed(index: Int) = withEntryModel(index) { + hasRepliedAllIcon() + } + + fun repliedIconIsDisplayed(index: Int) = withEntryModel(index) { + hasRepliedIcon() + } + + fun senderNameIsDisplayed(index: Int, value: String) = withEntryModel(index) { + hasSender(value) + } + + fun authenticityBadgeIsDisplayed(index: Int, value: Boolean) = withEntryModel(index) { + hasAuthenticityBadge(value) + } + + fun expirationIsDisplayed(index: Int, value: String) = withEntryModel(index) { + hasExpiration(value) + } + + fun starIconIsDisplayed(index: Int) = withEntryModel(index) { + hasStarIcon() + } + + fun timeIsDisplayed(index: Int, value: String) = withEntryModel(index) { + hasTime(value) + } + } + + private fun withEntryModel(index: Int, block: MessageCollapsedItemEntryModel.() -> MessageCollapsedItemEntryModel) { + val entryModel = MessageCollapsedItemEntryModel(index, composeTestRule) + block(entryModel) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt new file mode 100644 index 0000000000..f020119b33 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/DeviceRobot.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import ch.protonmail.android.MainActivity +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.test.robot.ProtonMailRobot +import ch.protonmail.android.uitest.util.ActivityScenarioHolder +import org.junit.Assert.assertEquals + +@AsDsl +internal class DeviceRobot : ProtonMailRobot { + + private val activityScenario: ActivityScenario + get() = ActivityScenarioHolder.scenario + + fun pressBack() { + Espresso.pressBackUnconditionally() + } + + fun triggerActivityRecreation() { + activityScenario.recreate() + } + + @VerifiesOuter + inner class Verify { + + fun isMainActivityNotDisplayed() { + assertEquals(Lifecycle.State.DESTROYED, activityScenario.state) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt new file mode 100644 index 0000000000..0df407148a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/MockRobot.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers + +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.robot.ProtonMailRobot + +@AsDsl +internal class MockRobot : ProtonMailRobot diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt new file mode 100644 index 0000000000..f7370ac6d2 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/models/NotificationEntry.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.models + +internal data class NotificationEntry( + val title: String, + val body: String? = null, + val isClearable: Boolean +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt new file mode 100644 index 0000000000..9991cf3650 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotExternalStorageSection.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.section + +import java.io.File +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.test.robot.ProtonMailSectionRobot +import ch.protonmail.android.uitest.robot.helpers.DeviceRobot +import kotlin.test.assertNotNull + +@AttachTo(targets = [DeviceRobot::class], identifier = "storage") +internal class DeviceRobotExternalStorageSection : ProtonMailSectionRobot { + + private val baseDir = File(DefaultDownloadPath) + + @VerifiesOuter + inner class Verify { + + private val String.rawName: String + get() = this.split(".")[0] + + private val String.extension: String + get() = this.split(".")[1] + + fun containsFileInDownloadsWithName(fileName: String) { + // Multiple files with the same name have "(1)", "(2)"... appended before the extension. + // We want to match "File.jpg"/"File (1).jpg" and so on. + val regex = "${fileName.rawName}(\\s)?(\\([0-9]+\\))?\\.${fileName.extension}".toRegex() + + val actualFile = baseDir.listFiles()?.any { + it.name.matches(regex) + } + + assertNotNull(actualFile) + } + } + + private companion object { + + const val DefaultDownloadPath = "/sdcard/Download" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt new file mode 100644 index 0000000000..69bc3dbb78 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotIntentsSection.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.section + +import android.content.Intent +import android.net.Uri +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.Intents.times +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.espresso.intent.matcher.IntentMatchers.hasType +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.helpers.DeviceRobot +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.any +import org.hamcrest.Matcher + +@AttachTo(targets = [DeviceRobot::class], identifier = "intents") +internal class DeviceRobotIntentsSection : ComposeSectionRobot() { + + @VerifiesOuter + inner class Verify { + + fun filePickerIntentWasLaunched( + times: Int = 1, + mimeType: String = "*/*", + timeout: Long = 5000L + ) { + composeTestRule.waitUntil(timeout) { + runCatching { + intended(allOf(hasAction(Intent.ACTION_GET_CONTENT), mimeType.asMimeTypeMatcher()), times(times)) + }.isSuccess + } + } + + fun actionViewUriIntentWasLaunched( + times: Int = 1, + url: String, + timeout: Long = 10_000L + ) { + composeTestRule.waitUntil(timeout) { + runCatching { + intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(Uri.parse(url))), times(times)) + }.isSuccess + } + } + + fun actionViewIntentWasLaunched( + times: Int = 1, + mimeType: String? = null, + timeout: Long = 10_000L + ) { + composeTestRule.waitUntil(timeout) { + runCatching { + intended(allOf(hasAction(Intent.ACTION_VIEW), mimeType.asMimeTypeMatcher()), times(times)) + }.isSuccess + } + } + + fun actionViewIntentWasNotLaunched(mimeType: String? = null) { + actionViewIntentWasLaunched(times = 0, mimeType = mimeType) + } + + private fun String?.asMimeTypeMatcher(): Matcher { + this ?: return hasType(any(String::class.java)) + return hasType(this) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt new file mode 100644 index 0000000000..abca580faa --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotNotificationsSection.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.section + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.service.notification.StatusBarNotification +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.helpers.DeviceRobot +import ch.protonmail.android.uitest.robot.helpers.models.NotificationEntry +import ch.protonmail.android.uitest.util.InstrumentationHolder +import kotlin.test.assertNotNull + +@AttachTo(targets = [DeviceRobot::class], identifier = "notificationsSection") +internal class DeviceRobotNotificationsSection : ComposeSectionRobot() { + + private val notificationManager: NotificationManager + get() = InstrumentationHolder.instrumentation.targetContext + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @VerifiesOuter + inner class Verify { + + private val StatusBarNotification.title: String? + get() = notification.extras.getString(Notification.EXTRA_TITLE) + + private val StatusBarNotification.body: String? + get() = notification.extras.getString(Notification.EXTRA_TEXT) + + fun hasNotificationDisplayed(entry: NotificationEntry) { + composeTestRule.waitForIdle() + + composeTestRule.waitUntil(NotificationTimeout) { + notificationManager.activeNotifications.isNotEmpty() + } + + val notification = notificationManager.activeNotifications.find { + entry.title == it.title && entry.body == it.body && entry.isClearable == it.isClearable + } + + assertNotNull(notification) + } + + fun hasNoNotificationsDisplayed() { + composeTestRule.waitForIdle() + + composeTestRule.waitUntil(NotificationTimeout) { + notificationManager.activeNotifications.isEmpty() + } + } + } + + private companion object { + + const val NotificationTimeout = 5000L + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt new file mode 100644 index 0000000000..3fa5e4f477 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/DeviceRobotSoftKeysSection.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.section + +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.robot.ProtonMailSectionRobot +import ch.protonmail.android.uitest.robot.helpers.DeviceRobot +import ch.protonmail.android.uitest.util.UiDeviceHolder + +@AttachTo(targets = [DeviceRobot::class], identifier = "deviceSoftKeys") +internal class DeviceRobotSoftKeysSection : ProtonMailSectionRobot { + + private val uiDevice = UiDeviceHolder.uiDevice + + fun pressHomeButton() { + uiDevice.pressHome() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt new file mode 100644 index 0000000000..1d7fbae624 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/helpers/section/MockRobotTimeSection.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.helpers.section + +import java.time.Instant +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.robot.ProtonMailSectionRobot +import ch.protonmail.android.uitest.robot.helpers.MockRobot +import io.mockk.every +import io.mockk.mockk + +@AttachTo(targets = [MockRobot::class], identifier = "time") +internal class MockRobotTimeSection : ProtonMailSectionRobot { + + fun forceCurrentMillisTo(millis: Long) { + every { Instant.now() } returns mockk { + every { nano } returns 0 + every { epochSecond } returns millis + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt new file mode 100644 index 0000000000..b051c6bcb9 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/MailboxRobot.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AsDsl +internal class MailboxRobot : ComposeRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.Root) + + @VerifiesOuter + inner class Verify { + + fun isShown() { + rootItem + .awaitDisplayed() + .assertIsDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt new file mode 100644 index 0000000000..d0e398e576 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/model/snackbar/MailboxSnackbar.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.model.snackbar + +import ch.protonmail.android.test.R +import ch.protonmail.android.uitest.models.snackbar.SnackbarEntry +import ch.protonmail.android.uitest.models.snackbar.SnackbarType +import ch.protonmail.android.uitest.util.getTestString + +internal sealed class MailboxSnackbar(value: String, type: SnackbarType) : SnackbarEntry(value, type) { + + object FailedToLoadNewItems : MailboxSnackbar( + getTestString(R.string.test_mailbox_error_message_generic), SnackbarType.Error + ) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt new file mode 100644 index 0000000000..01c75a85ba --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendErrorSection.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [MailboxRobot::class], identifier = "appendErrorSection") +internal class MailboxAppendErrorSection : ComposeSectionRobot() { + + private val appendErrorRootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxAppendError) + + private val errorDescription = appendErrorRootItem.child { + hasTestTag(MailboxScreenTestTags.MailboxAppendErrorText) + } + + private val retryButton = appendErrorRootItem.child { + hasTestTag(MailboxScreenTestTags.MailboxAppendErrorButton) + } + + fun tapRetryButton() { + retryButton.performClick() + } + + @VerifiesOuter + inner class Verify { + + fun isHidden() { + appendErrorRootItem + .awaitHidden() + .assertDoesNotExist() + } + + fun isShown() { + appendErrorRootItem.awaitDisplayed() + errorDescription.assertTextEquals(getTestString(testR.string.test_mailbox_error_message_generic)) + retryButton.assertTextEquals(getTestString(testR.string.test_retry)) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt new file mode 100644 index 0000000000..c31cc72209 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxAppendLoadingSection.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden + +@AttachTo(targets = [MailboxRobot::class], identifier = "appendLoadingSection") +internal class MailboxAppendLoadingSection : ComposeSectionRobot() { + + private val loadingItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxAppendLoader) + + @VerifiesOuter + inner class Verify { + + fun isShown() { + loadingItem.awaitDisplayed() + } + + fun isHidden() { + loadingItem.awaitHidden() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt new file mode 100644 index 0000000000..dd32d6665b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxEmptyListSection.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [MailboxRobot::class], identifier = "emptyListSection") +internal class MailboxEmptyListSection : ComposeSectionRobot(), RefreshableSection { + + private val emptyList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxEmptyRoot) + private val image = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptyImage) } + private val title = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptyTitle) } + private val subtitle = emptyList.child { hasTestTag(MailboxScreenTestTags.MailboxEmptySubtitle) } + + override fun pullDownToRefresh() { + emptyList.performTouchInput { swipeDown() } + } + + @VerifiesOuter + inner class Verify { + + fun isShown() { + image.awaitDisplayed() + title.awaitDisplayed() + subtitle.awaitDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt new file mode 100644 index 0000000000..95d3ab4bf4 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxErrorSection.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [MailboxRobot::class], identifier = "fullScreenErrorSection") +internal class MailboxErrorSection : ComposeSectionRobot(), RefreshableSection { + + private val rootItem = composeTestRule.onNodeWithTag(MailboxScreenTestTags.MailboxError) + private val errorLabel = rootItem.child { hasTestTag(MailboxScreenTestTags.MailboxErrorMessage) } + + override fun pullDownToRefresh() { + rootItem.performTouchInput { swipeDown() } + } + + @VerifiesOuter + inner class Verify { + + fun isShown() { + errorLabel + .awaitDisplayed() + .assertTextEquals(getTestString(testR.string.test_mailbox_error_message_generic)) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt new file mode 100644 index 0000000000..faaf050a61 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxListSection.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.test.espresso.action.ViewActions.swipeUp +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntryModel +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AttachTo(targets = [MailboxRobot::class], identifier = "listSection") +internal class MailboxListSection : ComposeSectionRobot(), RefreshableSection { + + private val messagesList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.List) + + override fun pullDownToRefresh() { + messagesList.performTouchInput { swipeDown() } + } + + fun longPressItemAtPosition(position: Int) = onListItemEntryModel(position) { + longClick() + } + + fun selectItemsAt(vararg positions: Int) = positions.forEach { + onListItemEntryModel(it) { selectEntry() } + } + + fun unselectItemsAtPosition(vararg positions: Int) = positions.forEach { + onListItemEntryModel(it) { unselectEntry() } + } + + fun clickMessageByPosition(position: Int) = onListItemEntryModel(position) { + click() + } + + fun scrollToItemAtIndex(index: Int) { + messagesList + .awaitDisplayed() + .performScrollToIndex(index) + } + + fun scrollToBottom() = apply { + messagesList + .awaitDisplayed() + .performTouchInput { swipeUp() } + } + + @VerifiesOuter + inner class Verify { + + fun listItemsAreShown(vararg mailboxItemEntries: MailboxListItemEntry) { + for (entry in mailboxItemEntries) { + onListItemEntryModel(entry.index) { + hasAvatar(entry.avatarInitial) + .hasParticipants(entry.participants) + .hasSubject(entry.subject) + .hasDate(entry.date) + + entry.locationIcons?.let { hasLocationIcons(it) } ?: hasNoLocationIcons() + entry.labels?.let { hasLabels(it) } ?: hasNoLabels() + entry.count?.let { hasCount(it) } ?: hasNoCount() + } + } + } + + fun selectedItemAtPosition(position: Int) { + onListItemEntryModel(position) { isSelected() } + } + + fun unSelectedItemAtPosition(position: Int) { + onListItemEntryModel(position) { isNotSelected() } + } + + fun unreadItemAtPosition(position: Int) = onListItemEntryModel(position) { + assertUnread() + } + + fun readItemAtPosition(position: Int) = onListItemEntryModel(position) { + assertRead() + } + } + + private fun onListItemEntryModel(position: Int, block: MailboxListItemEntryModel.() -> Unit) = + block(MailboxListItemEntryModel(position)) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt new file mode 100644 index 0000000000..875cd1a5be --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxProgressListSection.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AttachTo(targets = [MailboxRobot::class], identifier = "progressListSection") +internal class MailboxProgressListSection : ComposeSectionRobot() { + + private val progressList = composeTestRule.onNodeWithTag(MailboxScreenTestTags.ListProgress) + + @VerifiesOuter + inner class Verify { + + fun isShown() { + progressList + .awaitDisplayed() + .assertIsDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt new file mode 100644 index 0000000000..f6d812b19c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxStickyHeaderSection.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilterTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AttachTo(targets = [MailboxRobot::class], identifier = "stickyHeaderSection") +internal class MailboxStickyHeaderSection : ComposeSectionRobot() { + + private val unreadFilterChip = composeTestRule + .onNodeWithTag(UnreadItemsFilterTestTags.UnreadFilterChip) + + fun filterUnreadMessages() { + unreadFilterChip + .awaitDisplayed() + .performClick() + } + + @VerifiesOuter + inner class Verify { + + fun unreadFilterIsDisplayed() { + unreadFilterChip + .awaitDisplayed() + .assertIsNotSelected() + } + + fun unreadFilterIsSelected() { + unreadFilterChip + .awaitDisplayed() + .assertIsSelected() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt new file mode 100644 index 0000000000..d40b31b401 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/MailboxTopBarSection.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags.NavigationButton +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.mailbox.MailboxType +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString +import ch.protonmail.android.test.R as testR + +@AttachTo(targets = [MailboxRobot::class], identifier = "topAppBarSection") +internal class MailboxTopBarSection : ComposeSectionRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(MailboxTopAppBarTestTags.RootItem, useUnmergedTree = true) + + private val navigationButton = rootItem.child { + hasTestTag(NavigationButton) + } + + private val hamburgerMenuButton = navigationButton.child { + hasContentDescription( + getTestString(testR.string.test_mailbox_toolbar_menu_button_content_description) + ) + } + + private val exitSelectionButton = navigationButton.child { + hasContentDescription( + getTestString(testR.string.test_mailbox_toolbar_exit_selection_mode_button_content_description) + ) + } + + private val locationLabel = rootItem.child { + hasTestTag(MailboxTopAppBarTestTags.LocationLabel) + } + + private val composerButton = rootItem.child { + hasTestTag(MailboxTopAppBarTestTags.ComposerButton) + } + + fun tapComposerIcon() { + composerButton.awaitDisplayed().performClick() + } + + fun tapExitSelectionMode() { + exitSelectionButton.performClick() + } + + @VerifiesOuter + inner class Verify { + + /** + * Verifies that the current Mailbox is of the given [MailboxType]. + * + * A [timeout] is provided as switching Mailbox does not trigger any loaders or idling resources. + * By waiting for the condition to be fulfilled, we prevent the automation from performing the check + * before the Mailbox is effectively switched, avoiding unnecessary test flakiness. + * + * @param type the Mailbox type (Inbox, Drafts, Sent...) + * @param timeout the max timeout for the check to be successful + * + */ + fun isMailbox(type: MailboxType, timeout: Long = 2_000) { + composeTestRule.waitUntil(timeoutMillis = timeout) { + runCatching { locationLabel.assertTextEquals(type.name) }.isSuccess + } + + hamburgerMenuButton.awaitDisplayed() + } + + fun isInSelectionMode(numSelected: Int, timeout: Long = 2_000) { + composeTestRule.waitUntil(timeoutMillis = timeout) { + runCatching { locationLabel.assertTextEquals("$numSelected Selected") }.isSuccess + } + + hamburgerMenuButton.assertDoesNotExist() + exitSelectionButton.awaitDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt new file mode 100644 index 0000000000..e8a1796b8f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/mailbox/section/RefreshableSection.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.mailbox.section + +/** + * Interface for Robot sections that are expected to support the Pull to Refresh action. + */ +internal interface RefreshableSection { + + fun pullDownToRefresh() +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt new file mode 100644 index 0000000000..83225ff2ab --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/menu/MenuRobot.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +package ch.protonmail.android.uitest.robot.menu + +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.maillabel.presentation.sidebar.SidebarSystemLabelTestTags.BaseTag +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBarTestTags +import ch.protonmail.android.mailsidebar.presentation.SidebarMenuTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.models.folders.SidebarCustomItemEntry +import ch.protonmail.android.uitest.models.folders.SidebarItemCustomFolderEntryModel +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.robot.settings.SettingsRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden +import ch.protonmail.android.uitest.util.child +import ch.protonmail.android.uitest.util.getTestString +import me.proton.core.label.domain.entity.LabelId +import ch.protonmail.android.test.R as testR + +@AsDsl +internal class MenuRobot : ComposeRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(SidebarMenuTestTags.Root) + private val hamburgerMenuButton = composeTestRule.onNodeWithTag(MailboxTopAppBarTestTags.NavigationButton) + + fun openSidebarMenu(): MenuRobot = apply { + hamburgerMenuButton.awaitDisplayed().performClick() + rootItem.awaitDisplayed() + } + + fun openInbox() = openMailbox(SystemLabelId.Inbox) + + fun openDrafts() = openMailbox(SystemLabelId.Drafts) + + fun openArchive() = openMailbox(SystemLabelId.Archive) + + fun openSent() = openMailbox(SystemLabelId.Sent) + + fun openSpam() = openMailbox(SystemLabelId.Spam) + + fun openTrash() = openMailbox(SystemLabelId.Trash) + + fun openAllMail() = openMailbox(SystemLabelId.AllMail) + + fun openSettings(): SettingsRobot { + tapSidebarMenuItemWithText(getTestString(testR.string.test_mail_settings_settings)) + return SettingsRobot() + } + + fun openFolderWithName(folderName: String) { + tapSidebarMenuItemWithText(folderName) + } + + fun openReportBugs() { + tapSidebarMenuItemWithText(getTestString(testR.string.test_report_a_problem)) + } + + fun openSubscription() { + tapSidebarMenuItemWithText(getTestString(testR.string.test_subscription)) + } + + fun tapSignOut() { + tapSidebarMenuItemWithText(getTestString(testR.string.test_signout)) + } + + private fun tapSidebarMenuItemWithText(value: String) { + rootItem.onChild() + .apply { performScrollToNode(hasText(value)) } + .child { hasText(value) } + .performClick() + + composeTestRule.waitForIdle() + } + + private fun openMailbox(id: SystemLabelId): MailboxRobot { + composeTestRule + .onNodeWithTag(id.labelId.testTag) + .performScrollTo() + .performClick() + + rootItem.awaitHidden() + return MailboxRobot() + } + + @VerifiesOuter + inner class Verify { + + fun customFoldersAreDisplayed(vararg folders: SidebarCustomItemEntry) { + folders.forEach { + val item = SidebarItemCustomFolderEntryModel(it.index) + + item.hasText(it.name) + .withIconTint(it.iconTint) + } + } + } +} + +private val LabelId.testTag: String + get() = "$BaseTag#${this.id}" diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt new file mode 100644 index 0000000000..2cc95dba50 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/OnboardingRobot.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.onboarding + +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.awaitDisplayed + +@AsDsl +internal class OnboardingRobot : ComposeRobot() { + + private val rootItem = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem) + + @VerifiesOuter + inner class Verify { + + fun isShown() { + rootItem.awaitDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt new file mode 100644 index 0000000000..d8b125b15e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingBottomSection.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.onboarding.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.onboarding.OnboardingRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [OnboardingRobot::class], identifier = "bottomSection") +internal class OnboardingBottomSection : ComposeSectionRobot() { + + private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem) + private val bottomButton = parent.child { + hasTestTag(OnboardingScreenTestTags.BottomButton) + } + + // Interaction methods + + @VerifiesOuter + inner class Verify { + + fun isBottomButtonShown() { + bottomButton.awaitDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt new file mode 100644 index 0000000000..3f04529d4f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingMiddleSection.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.onboarding.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.onboarding.OnboardingRobot +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [OnboardingRobot::class], identifier = "middleSection") +internal class OnboardingMiddleSection : ComposeSectionRobot() { + + private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.RootItem) + private val onboardingImage = parent.child { + hasTestTag(OnboardingScreenTestTags.OnboardingImage) + } + + @VerifiesOuter + inner class Verify { + + fun isOnboardingImageShown() { + onboardingImage.isDisplayed() + } + } +} \ No newline at end of file diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt new file mode 100644 index 0000000000..0d076f36c5 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/onboarding/section/OnboardingTopBarSection.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.onboarding.section + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailonboarding.presentation.OnboardingScreenTestTags +import ch.protonmail.android.test.ksp.annotations.AttachTo +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeSectionRobot +import ch.protonmail.android.uitest.robot.onboarding.OnboardingRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.child + +@AttachTo(targets = [OnboardingRobot::class], identifier = "topBarSection") +internal class OnboardingTopBarSection : ComposeSectionRobot() { + + private val parent = composeTestRule.onNodeWithTag(OnboardingScreenTestTags.TopBarRootItem) + private val closeButton = parent.child { + hasTestTag(OnboardingScreenTestTags.CloseButton) + } + + // Interaction methods + + @VerifiesOuter + inner class Verify { + + fun isCloseButtonShown() { + closeButton.awaitDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt new file mode 100644 index 0000000000..4bcecd4261 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/AlternativeRoutingRobot.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings + +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot + +internal class AlternativeRoutingRobot : ComposeRobot() { + + fun turnOffAlternativeRouting(): AlternativeRoutingRobot { + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .performClick() + composeTestRule.waitUntil { AlternativeRoutingSettingIsToggled() } + return this + } + + private fun AlternativeRoutingSettingIsToggled(): Boolean { + try { + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .assertIsOff() + } catch (ignored: AssertionError) { + return false + } + return true + } + + @VerifiesOuter + inner class Verify { + + fun alternativeRoutingSettingIsToggled() { + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .assertIsOff() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt new file mode 100644 index 0000000000..a244211038 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/CombinedContactsRobot.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings + +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot + +internal class CombinedContactsRobot : ComposeRobot() { + + fun turnOnCombinedContacts(): CombinedContactsRobot { + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .performClick() + composeTestRule.waitUntil { combinedContactsSettingIsToggled() } + return this + } + + private fun combinedContactsSettingIsToggled(): Boolean { + try { + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .assertIsOn() + } catch (ignored: AssertionError) { + return false + } + return true + } + + @VerifiesOuter + inner class Verify { + + fun combinedContactsSettingIsToggled() { + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .assertIsOn() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt new file mode 100644 index 0000000000..acdd630362 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/LanguageRobot.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings + +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.mailsettings.domain.model.AppLanguage +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.settings.language.TEST_TAG_LANG_SETTINGS_SCREEN_SCROLL_COL +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.onNodeWithText + +internal class LanguageRobot : ComposeRobot() { + + fun selectBrazilianPortuguese(): LanguageRobot { + composeTestRule + .onNodeWithTag(TEST_TAG_LANG_SETTINGS_SCREEN_SCROLL_COL) + .performScrollToNode(hasText(AppLanguage.PORTUGUESE_BRAZILIAN.langName)) + + composeTestRule + .onNodeWithText(AppLanguage.PORTUGUESE_BRAZILIAN.langName) + .performClick() + + composeTestRule.waitForIdle() + return this + } + + fun selectSpanish(): LanguageRobot { + composeTestRule + .onNodeWithText(AppLanguage.SPANISH.langName) + .performClick() + composeTestRule.waitForIdle() + return this + } + + fun selectSystemDefault(): LanguageRobot { + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .performScrollTo() + .performClick() + composeTestRule.waitForIdle() + return this + } + + fun selectSystemDefaultFromBrazilian(): LanguageRobot { + composeTestRule + .onNodeWithText("Padrão do sistema") + .performScrollTo() + .performClick() + composeTestRule.waitForIdle() + return this + } + + @VerifiesOuter + inner class Verify { + + fun appLanguageChangedToPortuguese() { + verifyScreenTitleMatchesText("Idioma do aplicativo") + } + + fun appLanguageChangedToSpanish() { + verifyScreenTitleMatchesText("Idioma de la aplicación") + } + + fun brazilianPortugueseLanguageIsSelected() { + verifyLanguageIsSelected(AppLanguage.PORTUGUESE_BRAZILIAN.langName) + } + + fun spanishLanguageIsSelected() { + verifyLanguageIsSelected(AppLanguage.SPANISH.langName) + } + + fun defaultLanguageIsSelected() { + verifyScreenTitleMatchesText(string.mail_settings_app_language) + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsSelected() + + val languages = listOf("English", "Deutsch", "Français", "Nederlands", "Español (España)") + assertLanguagesAreShownButUnselected(languages) + } + + private fun assertLanguagesAreShownButUnselected(languages: List) { + languages.forEach { language -> + composeTestRule + .onNodeWithText(language) + .assertIsDisplayed() + .assertIsNotSelected() + } + } + + private fun verifyLanguageIsSelected(text: String) { + composeTestRule + .onNodeWithText(text) + .assertIsSelected() + } + + private fun verifyScreenTitleMatchesText(text: String) { + composeTestRule + .onNodeWithText(text) + .assertIsDisplayed() + } + + private fun verifyScreenTitleMatchesText(@StringRes text: Int) { + composeTestRule + .onNodeWithText(text) + .assertIsDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt new file mode 100644 index 0000000000..aee1fe762b --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/SettingsRobot.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +package ch.protonmail.android.uitest.robot.settings + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.settings.SettingsScreenTestTags +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.robot.settings.account.AccountSettingsRobot +import ch.protonmail.android.uitest.robot.settings.swipeactions.SwipeActionsRobot +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitProgressIsHidden +import ch.protonmail.android.uitest.util.hasText +import ch.protonmail.android.uitest.util.onNodeWithText + +internal class SettingsRobot : ComposeRobot() { + private val rootItem = composeTestRule.onNodeWithTag(SettingsScreenTestTags.RootItem) + + init { + rootItem.awaitDisplayed() + } + + fun openLanguageSettings(): LanguageRobot { + composeTestRule + .onNodeWithText(string.mail_settings_app_language) + .performClick() + composeTestRule.waitForIdle() + + return LanguageRobot() + } + + fun openSwipeActions(): SwipeActionsRobot { + composeTestRule + .onList() + .performScrollToNode(hasText(string.mail_settings_swipe_actions)) + + composeTestRule + .onNodeWithText(string.mail_settings_swipe_actions) + .performClick() + + composeTestRule.waitForIdle() + + return SwipeActionsRobot() + } + + fun openUserAccountSettings(): AccountSettingsRobot { + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.AccountSettingsItem) + .performClick() + + composeTestRule.awaitProgressIsHidden() + + return AccountSettingsRobot() + } + + fun openThemeSettings(): ThemeRobot { + composeTestRule + .onNodeWithText(string.mail_settings_theme) + .performClick() + composeTestRule.waitForIdle() + + return ThemeRobot() + } + + fun openCombinedContactsSettings(): CombinedContactsRobot { + composeTestRule + .onNodeWithText(string.mail_settings_combined_contacts) + .performClick() + composeTestRule.waitForIdle() + + return CombinedContactsRobot() + } + + fun openAlternativeRoutingSettings(): AlternativeRoutingRobot { + composeTestRule + .onNodeWithText(string.mail_settings_alternative_routing) + .performClick() + composeTestRule.waitForIdle() + + return AlternativeRoutingRobot() + } + + private fun ComposeTestRule.onList(): SemanticsNodeInteraction = + onAllNodes(hasScrollAction()).onFirst() // second is drawer +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt new file mode 100644 index 0000000000..2efbd118fb --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/ThemeRobot.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings + +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.onNodeWithText + +/** + * [ThemeRobot] class contains actions and verifications for ThemeSettingScreen + */ +internal class ThemeRobot : ComposeRobot() { + + fun selectDarkTheme(): ThemeRobot { + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .performClick() + composeTestRule.waitUntil { optionWithTextIsSelected(composeTestRule, string.mail_settings_theme_dark) } + return this + } + + fun selectSystemDefault(): ThemeRobot { + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .performClick() + composeTestRule.waitUntil { optionWithTextIsSelected(composeTestRule, string.mail_settings_system_default) } + return this + } + + private fun optionWithTextIsSelected( + composeTestRule: ComposeTestRule, + @StringRes text: Int + ): Boolean { + try { + composeTestRule + .onNodeWithText(text) + .assertIsSelected() + } catch (ignored: AssertionError) { + return false + } + return true + } + + @VerifiesOuter + inner class Verify { + + fun darkThemeIsSelected() { + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .assertIsSelected() + } + + fun defaultThemeSettingIsSelected() { + composeTestRule + .onNodeWithText(string.mail_settings_theme) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_light) + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .assertIsDisplayed() + .assertIsNotSelected() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt new file mode 100644 index 0000000000..def2d62113 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/AccountSettingsRobot.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +package ch.protonmail.android.uitest.robot.settings.account + +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_LIST +import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_SCREEN +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.hasText +import ch.protonmail.android.uitest.util.onNodeWithText +import me.proton.core.test.android.robots.settings.PasswordManagementRobot + +@AsDsl +internal class AccountSettingsRobot : ComposeRobot() { + + fun openConversationMode(): ConversationModeRobot { + clickOnAccountListItemWithText(string.mail_settings_conversation_mode) + return ConversationModeRobot() + } + + fun openPasswordManagement(): PasswordManagementRobot { + clickOnAccountListItemWithText(string.mail_settings_password_management) + return PasswordManagementRobot() + } + + private fun clickOnAccountListItemWithText(@StringRes itemNameRes: Int) { + composeTestRule + .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST) + .onChild() + .performScrollToNode(hasText(itemNameRes)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(itemNameRes) + .performClick() + composeTestRule.waitForIdle() + } + + @VerifiesOuter + inner class Verify { + + fun accountSettingsScreenIsDisplayed() { + composeTestRule.waitUntil(timeoutMillis = 5000) { + composeTestRule + .onAllNodesWithTag(TEST_TAG_ACCOUNT_SETTINGS_SCREEN) + .fetchSemanticsNodes(false) + .isNotEmpty() + } + composeTestRule + .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_SCREEN) + .assertIsDisplayed() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt new file mode 100644 index 0000000000..71b1d9b636 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/account/ConversationModeRobot.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +package ch.protonmail.android.uitest.robot.settings.account + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.onFirst +import ch.protonmail.android.mailsettings.presentation.R +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.test.ksp.annotations.AsDsl +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.assertions.assertTextContains +import ch.protonmail.android.uitest.util.onAllNodesWithText + +@AsDsl +internal class ConversationModeRobot : ComposeRobot() { + + @VerifiesOuter + inner class Verify { + + fun conversationModeToggleIsDisplayedAndEnabled() { + composeTestRule + .onAllNodesWithText(R.string.mail_settings_conversation_mode) + // Take first as both "toolbar" and "switch" node are matching the same text + .onFirst() + .assertTextContains(string.mail_settings_conversation_mode_hint) + .assertIsDisplayed() + .assertIsEnabled() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt new file mode 100644 index 0000000000..117d0b66d7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/EditSwipeActionRobot.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings.swipeactions + +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.onAllNodesWithText +import ch.protonmail.android.uitest.util.onNodeWithContentDescription +import me.proton.core.presentation.compose.R.string as coreString + +internal class EditSwipeActionRobot : ComposeRobot() { + + fun navigateUpToSwipeActions(): SwipeActionsRobot { + composeTestRule + .onNodeWithContentDescription(coreString.presentation_back) + .performClick() + + return SwipeActionsRobot() + } + + fun selectArchive(): EditSwipeActionRobot { + composeTestRule + .onAllNodesWithText(string.mail_settings_swipe_action_archive_description)[0] + .performClick() + + return this + } + + fun selectMarkRead(): EditSwipeActionRobot { + composeTestRule + .onAllNodesWithText(string.mail_settings_swipe_action_read_description)[0] + .performClick() + + return this + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt new file mode 100644 index 0000000000..b4fb8b125f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/robot/settings/swipeactions/SwipeActionsRobot.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.robot.settings.swipeactions + +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.test.ksp.annotations.VerifiesOuter +import ch.protonmail.android.uitest.robot.ComposeRobot +import ch.protonmail.android.uitest.util.assertions.assertTextContains +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.onNodeWithText + +internal class SwipeActionsRobot : ComposeRobot() { + + fun openSwipeLeft(): EditSwipeActionRobot { + composeTestRule + .onNodeWithText(string.mail_settings_swipe_left_name) + .awaitDisplayed() + .performClick() + + return EditSwipeActionRobot() + } + + fun openSwipeRight(): EditSwipeActionRobot { + composeTestRule + .onNodeWithText(string.mail_settings_swipe_right_name) + .awaitDisplayed() + .performClick() + + return EditSwipeActionRobot() + } + + @VerifiesOuter + inner class Verify { + + inline fun swipeLeft(block: VerifySwipeAction.() -> Unit): VerifySwipeAction = + VerifySwipeAction(composeTestRule, composeTestRule.onNodeWithText(string.mail_settings_swipe_left_name)) + .apply(block) + + inline fun swipeRight(block: VerifySwipeAction.() -> Unit): VerifySwipeAction = + VerifySwipeAction(composeTestRule, composeTestRule.onNodeWithText(string.mail_settings_swipe_right_name)) + .apply(block) + + inner class VerifySwipeAction( + private val composeTestRule: ComposeTestRule, + private val interaction: SemanticsNodeInteraction + ) { + + fun isArchive() { + assertHasText(string.mail_settings_swipe_action_archive_title) + } + + fun isMarkRead() { + assertHasText(string.mail_settings_swipe_action_read_title) + } + + private fun assertHasText(@StringRes textRes: Int) { + interaction + .awaitDisplayed() + .assertTextContains(textRes) + .assertIsDisplayed() + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt new file mode 100644 index 0000000000..199940b98e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/GrantNotificationsPermissionRule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import android.Manifest +import android.os.Build +import ch.protonmail.android.uitest.util.InstrumentationHolder +import org.junit.rules.ExternalResource + +/** + * A custom rule to allow Notifications Permission to be granted on API >= 33. + */ +internal class GrantNotificationsPermissionRule : ExternalResource() { + + override fun before() { + super.before() + + // Not needed before API 33, as Notifications Permission does not exist before Tiramisu. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val instrumentation = InstrumentationHolder.instrumentation + val uiAutomation = instrumentation.uiAutomation + val packageName = instrumentation.targetContext.packageName + + uiAutomation.grantRuntimePermission(packageName, Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt new file mode 100644 index 0000000000..2eebb86dd7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/HiltInjectRule.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import dagger.hilt.android.testing.HiltAndroidRule +import org.junit.rules.ExternalResource + +class HiltInjectRule(private val rule: HiltAndroidRule) : ExternalResource() { + + override fun before() { + rule.inject() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt new file mode 100644 index 0000000000..bb7b1eba2a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MainInitializerRule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import androidx.test.core.app.ApplicationProvider +import ch.protonmail.android.initializer.MainInitializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.rules.ExternalResource + +/** + * A custom rule to initialize the [MainInitializer] before each test. + */ +class MainInitializerRule : ExternalResource() { + + override fun before() { + super.before() + runBlocking { + withContext(Dispatchers.Main) { MainInitializer.init(ApplicationProvider.getApplicationContext()) } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt new file mode 100644 index 0000000000..3bd399f4b8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockIntentsRule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import android.app.Activity +import android.app.Instrumentation.ActivityResult +import android.content.Intent +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import org.junit.rules.ExternalResource + +internal class MockIntentsRule(private val captureIntents: Boolean) : ExternalResource() { + + private val fakeActivityResult = ActivityResult(Activity.RESULT_OK, Intent()) + + override fun before() { + if (!captureIntents) return + + Intents.init() + + // Attachment viewing + Intents.intending(hasAction(Intent.ACTION_VIEW)).respondWith(fakeActivityResult) + } + + override fun after() { + if (!captureIntents) return + + Intents.release() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt new file mode 100644 index 0000000000..d4a7e51005 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockOnboardingRule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import ch.protonmail.android.mailonboarding.data.local.OnboardingLocalDataSource +import ch.protonmail.android.mailonboarding.domain.model.OnboardingPreference +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +internal class MockOnboardingRuntimeRule @Inject constructor( + private val onboardingLocalDataSource: OnboardingLocalDataSource +) { + + operator fun invoke(shouldForceShow: Boolean) = runBlocking { + onboardingLocalDataSource.save(OnboardingPreference(shouldForceShow)) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt new file mode 100644 index 0000000000..05c293a0b8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/MockTimeRule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import java.time.Instant +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.rules.ExternalResource + +/** + * A custom rule to allow mocking the [Instant] class when running tests. + */ +internal class MockTimeRule : ExternalResource() { + + override fun before() { + super.before() + mockkStatic(Instant::class) + } + + override fun after() { + unmockkAll() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt new file mode 100644 index 0000000000..a06bf33b7d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/rule/SpotlightSeenRule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.rule + +import ch.protonmail.android.mailsettings.domain.repository.LocalSpotlightEventsRepository +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +internal class SpotlightSeenRule @Inject constructor( + private val repo: LocalSpotlightEventsRepository +) { + + operator fun invoke(seen: Boolean) = runBlocking { + if (seen) { + repo.markCustomizeToolbarSeen() + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt new file mode 100644 index 0000000000..8e6ca63da3 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/common/BottomActionBarTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.common + +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.ui.BottomActionBar +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.testdata.action.ActionUiModelTestData +import ch.protonmail.android.uitest.robot.common.BottomActionBarRobot +import ch.protonmail.android.uitest.robot.common.verify +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +internal class BottomActionBarTest : HiltInstrumentedTest() { + + @Test + fun whenBottomBarStateIsLoadingDisplayLoader() { + // given + val state = BottomBarState.Loading + + // when + val robot = setupScreen(state = state) + + // then + robot.verify { loaderIsDisplayed() } + } + + @Test + fun whenBottomBarStateIsFailedLoadingActionsDisplayError() { + // given + val state = BottomBarState.Error.FailedLoadingActions + + // when + val robot = setupScreen(state = state) + + // then + robot.verify { failedLoadingErrorIsDisplayed() } + } + + @Test + fun whenBottomBarStateIsDataUpToMaxActionsAreShowed() { + // given + val state = BottomBarState.Data.Shown( + listOf( + ActionUiModelTestData.star, + ActionUiModelTestData.delete, + ActionUiModelTestData.archive, + ActionUiModelTestData.move, + ActionUiModelTestData.label, + ActionUiModelTestData.markUnread, + ActionUiModelTestData.reportPhishing + ).toImmutableList() + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.verify { + errorAndLoaderHidden() + + actionIsDisplayed(Action.Star) + actionIsDisplayed(Action.Delete) + actionIsDisplayed(Action.Archive) + actionIsDisplayed(Action.Move) + actionIsDisplayed(Action.Label) + actionIsDisplayed(Action.MarkUnread) + + actionIsNotDisplayed(Action.ReportPhishing) + } + } + + private fun setupScreen( + state: BottomBarState, + actions: BottomActionBar.Actions = BottomActionBar.Actions.Empty + ): BottomActionBarRobot = composeTestRule.BottomActionBarRobot { + BottomActionBar(state = state, viewActionCallbacks = actions) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt new file mode 100644 index 0000000000..e8ee11eb94 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/ConversationDetailScreenTest.kt @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.detail + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.AvatarUiModel +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.sample.ActionUiModelSample +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMessageUiModel +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMetadataState +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailState +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailsMessagesState +import ch.protonmail.android.maildetail.presentation.previewdata.ConversationDetailsPreviewData +import ch.protonmail.android.maildetail.presentation.sample.ConversationDetailMessageUiModelSample +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.messagesCollapsedSection +import ch.protonmail.android.uitest.robot.detail.section.conversation.verify +import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import ch.protonmail.android.uitest.robot.detail.verify +import ch.protonmail.android.uitest.util.getString +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.collections.immutable.toImmutableList +import org.junit.Ignore +import kotlin.test.Test +import kotlin.test.assertTrue + +@Suppress("TooManyFunctions") +@RegressionTest +@HiltAndroidTest +internal class ConversationDetailScreenTest : HiltInstrumentedTest() { + + @Test + fun whenConversationIsLoadedThenSubjectIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds + val conversationState = state.conversationState as ConversationDetailMetadataState.Data + + // when + val robot = setupScreen(state = state) + + // then + robot.detailTopBarSection { + verify { hasSubject(conversationState.conversationUiModel.subject) } + } + } + + @Test + fun whenMessageIsLoadedSenderInitialIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds + + // when + val robot = setupScreen(state = state) + + // then + val messagesState = state.messagesState as ConversationDetailsMessagesState.Data + when (val firstMessage = messagesState.messages.first()) { + is ConversationDetailMessageUiModel.Collapsed -> { + val initial = firstMessage.avatar as AvatarUiModel.ParticipantInitial + robot.messagesCollapsedSection { + verify { avatarInitialIsDisplayed(index = 0, text = initial.value) } + } + } + + is ConversationDetailMessageUiModel.Expanded -> Unit + is ConversationDetailMessageUiModel.Expanding -> Unit + is ConversationDetailMessageUiModel.Hidden -> Unit + } + } + + @Test + fun whenMessageIsLoadedTimeIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds + + // when + val robot = setupScreen(state = state) + + // then + robot.run { + val messagesState = state.messagesState as ConversationDetailsMessagesState.Data + when (val firstMessage = messagesState.messages.first()) { + is ConversationDetailMessageUiModel.Collapsed -> { + messagesCollapsedSection { + verify { timeIsDisplayed(index = 0, value = getString(firstMessage.shortTime)) } + } + } + + is ConversationDetailMessageUiModel.Expanded -> { + messageHeaderSection { + verify { + hasTime( + value = getString(firstMessage.messageDetailHeaderUiModel.time) + ) + } + } + } + + is ConversationDetailMessageUiModel.Expanding -> Unit + is ConversationDetailMessageUiModel.Hidden -> Unit + } + } + } + + @Test + fun whenDraftMessageIsLoadedDraftIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.EmptyDraft + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { avatarDraftIsDisplayed(index = 0) } + } + } + + @Test + fun whenRepliedMessageIsLoadedRepliedIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceReplied + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { repliedIconIsDisplayed(index = 0) } + } + } + + @Test + fun whenRepliedAllMessageIsLoadedRepliedIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceRepliedAll + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { repliedAllIconIsDisplayed(index = 0) } + } + } + + @Test + fun whenForwardedMessageIsLoadedForwardedIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceForwarded + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { forwardedIconIsDisplayed(index = 0) } + } + } + + @Test + fun whenMessagesAreLoadedThenSenderIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds + + // when + val robot = setupScreen(state = state) + + // then + robot.run { + val messagesState = state.messagesState as ConversationDetailsMessagesState.Data + when (val firstMessage = messagesState.messages.first()) { + is ConversationDetailMessageUiModel.Collapsed -> + messagesCollapsedSection { + verify { senderNameIsDisplayed(index = 0, value = firstMessage.sender.participantName) } + } + + is ConversationDetailMessageUiModel.Expanded -> verify { + messageHeaderSection { + verify { + hasSenderName(firstMessage.messageDetailHeaderUiModel.sender.participantName) + } + } + } + + is ConversationDetailMessageUiModel.Expanding -> Unit + is ConversationDetailMessageUiModel.Hidden -> Unit + } + } + } + + @Test + fun whenMessageWithExpirationIsLoadedThenExpirationIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.ExpiringInvitation + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { expirationIsDisplayed(index = 0, value = "12h") } + } + } + + @Test + fun whenStarredMessageIsLoadedThenStarIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.StarredInvoice + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { + starIconIsDisplayed(index = 0) + } + } + } + + @Test + @Ignore("The component is correctly displayed, but the test fails to match it") + fun whenMessageWithAttachmentIsLoadedThenAttachmentIconIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { + attachmentIconIsDisplayed(index = 0) + } + } + } + + @Test + fun whenTrashIsClickedThenActionIsCalled() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy( + bottomBarState = BottomBarState.Data.Shown( + actions = listOf(ActionUiModelSample.Trash).toImmutableList() + ) + ) + + // when + var trashClicked = false + val robot = setupScreen( + state = state, + actions = ConversationDetailScreen.Actions.Empty.copy( + onTrashClick = { trashClicked = true } + ) + ) + + robot.bottomBarSection { moveToTrash() } + + // then + assertTrue(trashClicked) + } + + @Test + fun whenErrorThenErrorMessageIsDisplayed() { + // given + val message = "Something terrible happened!" + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy( + error = Effect.of(TextUiModel(message)) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messageBodySection { + verify { loadingErrorMessageIsDisplayed(message) } + } + } + + @Test + fun whenUnreadClickedThenCallbackIsInvoked() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy( + bottomBarState = BottomBarState.Data.Shown( + actions = listOf(ActionUiModelSample.MarkUnread).toImmutableList() + ) + ) + var unreadClicked = false + + // when + val robot = setupScreen( + state = state, + actions = ConversationDetailScreen.Actions.Empty.copy( + onUnreadClick = { unreadClicked = true } + ) + ) + + robot.bottomBarSection { markAsUnread() } + + // then + assertTrue(unreadClicked) + } + + @Test + fun whenExitStateThenCallbackIsInvoked() { + // given + val state = ConversationDetailsPreviewData.SuccessWithRandomMessageIds.copy( + exitScreenEffect = Effect.of(Unit) + ) + var didExit = false + + // when + setupScreen( + state = state, + actions = ConversationDetailScreen.Actions.Empty.copy( + onExit = { didExit = true } + ) + ) + + // then + assertTrue(didExit) + } + + @Test + fun whenOfflineStateThenOfflineErrorMessageIsDisplayed() { + // given + val message = "You're offline. Please go back online to load messages" + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Offline + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messageBodySection { + verify { loadingErrorMessageIsDisplayed(message) } + } + } + + @Test + fun whenConversationWithExpandedMessagesIsLoadedThenMessageHeaderIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messageHeaderSection { + verify { headerIsDisplayed() } + } + } + + @Test + fun whenConversationWithExpandedMessagesIsLoadedThenMessageBodyIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messageBodySection { + verify { + messageInWebViewContains( + ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded.messageBodyUiModel.messageBody + ) + } + } + } + + @Test + fun whenConversationWithExpandedMessagesIsLoadedThenTheCollapsedHeaderIsNotDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success.copy( + messagesState = ConversationDetailsMessagesState.Data( + messages = listOf( + ConversationDetailMessageUiModelSample.InvoiceWithLabelExpanded + ).toImmutableList() + ) + ) + + // when + val robot = setupScreen(state = state) + + // then + robot.messagesCollapsedSection { + verify { collapsedHeaderIsNotDisplayed() } + } + } + + private fun setupScreen( + state: ConversationDetailState, + actions: ConversationDetailScreen.Actions = ConversationDetailScreen.Actions.Empty + ): ConversationDetailRobot = conversationDetailRobot { + this@ConversationDetailScreenTest.composeTestRule.setContent { + ConversationDetailScreen(state = state, actions = actions, scrollToMessageId = null) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt new file mode 100644 index 0000000000..e1869926bf --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/DetailScreenTopBarTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.detail + +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailMetadataState +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailState +import ch.protonmail.android.maildetail.presentation.model.MessageDetailState +import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState +import ch.protonmail.android.maildetail.presentation.previewdata.ConversationDetailsPreviewData +import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData +import ch.protonmail.android.maildetail.presentation.ui.ConversationDetailScreen +import ch.protonmail.android.maildetail.presentation.ui.DetailScreenTopBar +import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.robot.detail.ConversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.MessageDetailRobot +import ch.protonmail.android.uitest.robot.detail.conversationDetailRobot +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.detailTopBarSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +internal class DetailScreenTopBarTest : HiltInstrumentedTest() { + + @Test + fun whenConversationIsLoadingThenSubjectContainsABlankString() { + // given + val state = ConversationDetailsPreviewData.Loading + + // when + val robot = setupScreen(state = state) + + // then + robot.detailTopBarSection { + verify { hasSubject(DetailScreenTopBar.NoTitle) } + } + } + + @Test + fun whenMessageIsLoadingThenSubjectContainsABlankString() { + // given + val state = MessageDetailsPreviewData.Loading + + // when + val robot = setupScreen(state = state) + + // then + robot.detailTopBarSection { + verify { hasSubject(DetailScreenTopBar.NoTitle) } + } + } + + @Test + fun whenConversationIsLoadedThenSubjectIsDisplayed() { + // given + val state = ConversationDetailsPreviewData.Success + val conversationState = state.conversationState as ConversationDetailMetadataState.Data + + // when + val robot = setupScreen(state = state) + + // then + robot.detailTopBarSection { + verify { hasSubject(conversationState.conversationUiModel.subject) } + } + } + + @Test + fun whenMessageIsLoadedThenSubjectIsDisplayed() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + + // when + val robot = setupScreen(state = state) + + // then + robot.detailTopBarSection { + verify { hasSubject(messageState.messageDetailActionBar.subject) } + } + } + + private fun setupScreen( + state: ConversationDetailState, + actions: ConversationDetailScreen.Actions = ConversationDetailScreen.Actions.Empty + ): ConversationDetailRobot = conversationDetailRobot { + this@DetailScreenTopBarTest.composeTestRule.setContent { + ConversationDetailScreen(state = state, actions = actions, scrollToMessageId = null) + } + } + + private fun setupScreen( + state: MessageDetailState, + actions: MessageDetailScreen.Actions = MessageDetailScreen.Actions.Empty + ): MessageDetailRobot = messageDetailRobot { + this@DetailScreenTopBarTest.composeTestRule.setContent { + MessageDetailScreen(state = state, actions = actions) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt new file mode 100644 index 0000000000..b9f765b5fd --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageBodyTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.detail + +import java.util.UUID +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities.Capabilities +import ch.protonmail.android.mailcommon.presentation.system.LocalDeviceCapabilitiesProvider +import ch.protonmail.android.maildetail.presentation.sample.MessageDetailBodyUiModelSample +import ch.protonmail.android.maildetail.presentation.ui.MessageBody +import ch.protonmail.android.maildetail.presentation.ui.MessageBodyTestTags +import ch.protonmail.android.mailmessage.presentation.model.MessageBodyExpandCollapseMode +import ch.protonmail.android.mailmessage.presentation.ui.MessageBodyWebViewTestTags +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +class MessageBodyTest : HiltInstrumentedTest() { + + @Test + fun shouldDisplayWebViewIfAvailable() { + // given + val messageContent = UUID.randomUUID().toString() + val state = MessageDetailBodyUiModelSample.build(messageContent) + + // when + composeTestRule.setContent { + CompositionLocalProvider(LocalDeviceCapabilitiesProvider provides Capabilities(hasWebView = true)) { + MessageBody( + modifier = Modifier, + messageBodyUiModel = state, + actions = EmptyActions, + expandCollapseMode = MessageBodyExpandCollapseMode.NotApplicable + ) + } + } + + // then + composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView).assertExists() + composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative).assertDoesNotExist() + } + + @Test + fun shouldDisplayWebViewAlternativeWhenWebViewNotAvailable() { + // given + val messageContent = UUID.randomUUID().toString() + val state = MessageDetailBodyUiModelSample.build(messageContent) + + // when + composeTestRule.setContent { + CompositionLocalProvider(LocalDeviceCapabilitiesProvider provides Capabilities(hasWebView = false)) { + MessageBody( + modifier = Modifier, + messageBodyUiModel = state, + actions = EmptyActions, + expandCollapseMode = MessageBodyExpandCollapseMode.NotApplicable + ) + } + } + + // then + composeTestRule.onNodeWithTag(MessageBodyWebViewTestTags.WebView).assertDoesNotExist() + composeTestRule.onNodeWithTag(MessageBodyTestTags.WebViewAlternative).assertExists() + } +} + +private val EmptyActions = MessageBody.Actions( + onExpandCollapseButtonClicked = {}, + onMessageBodyLinkClicked = {}, + onShowAllAttachments = {}, + onAttachmentClicked = {}, + loadEmbeddedImage = { _, _ -> null }, + onReply = {}, + onReplyAll = {}, + onForward = {}, + onEffectConsumed = { _, _ -> }, + onLoadRemoteContent = {}, + onLoadEmbeddedImages = {}, + onLoadRemoteAndEmbeddedContent = {}, + onOpenInProtonCalendar = {}, + onPrint = {}, + onViewEntireMessageClicked = { _, _, _, _ -> }, +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt new file mode 100644 index 0000000000..2e0d16d804 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailHeaderTestData.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.detail + +import ch.protonmail.android.maildetail.presentation.R +import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState +import ch.protonmail.android.maildetail.presentation.model.ParticipantUiModel +import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData +import kotlinx.collections.immutable.toImmutableList + +internal object MessageDetailHeaderTestData { + + val MessageWithOneRecipient = BaseMessage.copy( + messageMetadataState = BaseMetadataState.copy(messageDetailHeader = HeaderWithOneRecipient) + ) + + val MessageWithMultipleRecipients = BaseMessage.copy( + messageMetadataState = BaseMetadataState.copy(messageDetailHeader = HeaderWithMultipleRecipients) + ) +} + +private val BaseParticipantUiModel = ParticipantUiModel( + "one", + "address1@proton.me", + participantPadlock = R.drawable.ic_proton_lock, + shouldShowOfficialBadge = false +) + +private val BaseMessage = MessageDetailsPreviewData.Message +private val BaseMetadataState = BaseMessage.messageMetadataState as MessageMetadataState.Data +private val BaseMessageDetailHeader = BaseMetadataState.messageDetailHeader + +private val HeaderWithOneRecipient = BaseMessageDetailHeader.copy( + toRecipients = listOf(BaseParticipantUiModel).toImmutableList(), + ccRecipients = emptyList().toImmutableList(), + bccRecipients = emptyList().toImmutableList() +) + +private val HeaderWithMultipleRecipients = BaseMessageDetailHeader.copy( + toRecipients = listOf(BaseParticipantUiModel).toImmutableList(), + ccRecipients = listOf(BaseParticipantUiModel.copy(participantAddress = "address2@proton.me")).toImmutableList(), + bccRecipients = emptyList().toImmutableList() +) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt new file mode 100644 index 0000000000..bf3729b0a0 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/detail/MessageDetailScreenTest.kt @@ -0,0 +1,460 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.detail + +import android.net.Uri +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.AvatarUiModel +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.sample.ActionUiModelSample +import ch.protonmail.android.maildetail.presentation.R +import ch.protonmail.android.maildetail.presentation.model.MessageBodyState +import ch.protonmail.android.maildetail.presentation.model.MessageDetailState +import ch.protonmail.android.maildetail.presentation.model.MessageMetadataState +import ch.protonmail.android.maildetail.presentation.previewdata.MessageDetailsPreviewData +import ch.protonmail.android.maildetail.presentation.ui.MessageDetailScreen +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.testdata.message.MessageBodyUiModelTestData +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.detail.ExtendedHeaderRecipientEntry +import ch.protonmail.android.uitest.models.labels.LabelEntry +import ch.protonmail.android.uitest.robot.detail.messageDetailRobot +import ch.protonmail.android.uitest.robot.detail.section.bottomBarSection +import ch.protonmail.android.uitest.robot.detail.section.messageBodySection +import ch.protonmail.android.uitest.robot.detail.section.messageHeaderSection +import ch.protonmail.android.uitest.robot.detail.section.verify +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.collections.immutable.toImmutableList +import kotlin.test.Test +import kotlin.test.assertTrue + +@RegressionTest +@HiltAndroidTest +internal class MessageDetailScreenTest : HiltInstrumentedTest() { + + @Test + fun whenMessageIsLoadedThenMessageHeaderIsDisplayed() { + // given + val state = MessageDetailsPreviewData.Message + + // when + val robot = messageDetailRobot { setUpScreen(state = state) } + + // then + robot.messageHeaderSection { + verify { headerIsDisplayed() } + } + } + + @Test + fun whenMessageIsLoadedThenAvatarIsDisplayedInMessageHeader() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val avatarInitial = (messageState.messageDetailHeader.avatar as AvatarUiModel.ParticipantInitial).run { + AvatarInitial.WithText(value) + } + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasAvatarInitial(avatarInitial) } + } + } + + @Test + fun whenMessageIsLoadedThenSenderNameIsDisplayedInMessageHeader() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasSenderName(messageState.messageDetailHeader.sender.participantName) } + } + } + + @Test + fun whenMessageIsLoadedThenSenderAddressIsDisplayedInMessageHeader() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasSenderAddress(messageState.messageDetailHeader.sender.participantAddress) } + } + } + + @Test + fun whenMessageIsLoadedThenTimeIsDisplayedInMessageHeader() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val time = messageState.messageDetailHeader.time as TextUiModel.Text + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasTime(time.value) } + } + } + + @Test + fun whenMessageIsLoadedThenRecipientsAreDisplayedInMessageHeader() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val recipients = messageState.messageDetailHeader.allRecipients as TextUiModel.Text + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasRecipient(recipients.value) } + } + } + + @Test + fun whenMessageIsLoadedAndMessageHeaderIsClickedThenExpandedRecipientsAreDisplayed() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val recipients = messageState.messageDetailHeader.toRecipients.mapIndexed { idx: Int, element -> + ExtendedHeaderRecipientEntry.To(index = idx, element.participantName, element.participantAddress) + }.toTypedArray() + + val robot = setUpScreen(state = state) + + // when + robot.messageHeaderSection { expandHeader() } + + // then + robot.messageHeaderSection { + expanded { + verify { hasRecipients(*recipients) } + } + } + } + + @Test + fun whenMessageIsLoadedAndMessageHeaderIsClickedThenExtendedTimeIsShown() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val time = messageState.messageDetailHeader.extendedTime as TextUiModel.Text + val robot = setUpScreen(state = state) + + // when + robot.messageHeaderSection { expandHeader() } + + // then + robot.messageHeaderSection { + expanded { + verify { hasTime(time.value) } + } + } + } + + @Test + fun whenMessageIsLoadedAndMessageHeaderIsClickedThenLocationNameIsShown() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val robot = setUpScreen(state = state) + + // when + robot.messageHeaderSection { expandHeader() } + + // then + robot.messageHeaderSection { + expanded { + verify { hasLocation(messageState.messageDetailHeader.location.name) } + } + } + } + + @Test + fun whenMessageIsLoadedAndMessageHeaderIsClickedThenMessageSizeIsShown() { + // given + val state = MessageDetailsPreviewData.Message + val messageState = state.messageMetadataState as MessageMetadataState.Data + val robot = setUpScreen(state = state) + + // when + robot.messageHeaderSection { expandHeader() } + + // then + robot.messageHeaderSection { + expanded { + verify { hasSize(messageState.messageDetailHeader.size) } + } + } + } + + @Test + fun whenMessageIsLoadedAndHasOneRecipientThenReplyQuickActionButtonIsShown() { + // given + val state = MessageDetailHeaderTestData.MessageWithOneRecipient + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasReplyButton() } + } + } + + @Test + fun whenMessageIsLoadedAndHasMoreThanOneRecipientThenReplyAllQuickActionButtonIsShown() { + // given + val state = MessageDetailHeaderTestData.MessageWithMultipleRecipients + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasReplyAllButton() } + } + } + + @Test + fun whenTrashIsClickedThenActionIsCalled() { + // given + val state = MessageDetailsPreviewData.Message.copy( + bottomBarState = BottomBarState.Data.Shown( + actions = listOf(ActionUiModelSample.Trash).toImmutableList() + ) + ) + + var trashClicked = false + + val robot = setUpScreen( + state = state, + actions = MessageDetailScreen.Actions.Empty.copy( + onTrashClick = { trashClicked = true } + ) + ) + + // when + robot.bottomBarSection { moveToTrash() } + + // then + assertTrue(trashClicked) + } + + @Test + fun whenMessageWithLabelsIsLoadedThenFirstLabelIsDisplayed() { + // given + val state = MessageDetailsPreviewData.MessageWithLabels + val label = (state.messageMetadataState as MessageMetadataState.Data).messageDetailHeader.labels.first() + val labelEntry = LabelEntry(index = 0, text = label.name) + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageHeaderSection { + verify { hasLabels(labelEntry) } + } + } + + @Test + fun whenUnreadClickedThenCallbackIsInvoked() { + // given + val state = MessageDetailsPreviewData.Message.copy( + bottomBarState = BottomBarState.Data.Shown( + actions = listOf(ActionUiModelSample.MarkUnread).toImmutableList() + ) + ) + var unreadClicked = false + + // when + val robot = setUpScreen( + state = state, + actions = MessageDetailScreen.Actions.Empty.copy( + onUnreadClick = { unreadClicked = true } + ) + ) + + robot.bottomBarSection { markAsUnread() } + + // then + assertTrue(unreadClicked) + } + + @Test + fun whenExitStateThenCallbackIsInvoked() { + // given + val state = MessageDetailsPreviewData.Message.copy( + exitScreenEffect = Effect.of(Unit) + ) + var didExit = false + + // when + setUpScreen( + state = state, + actions = MessageDetailScreen.Actions.Empty.copy( + onExit = { didExit = true } + ) + ) + + // then + assertTrue(didExit) + } + + @Test + fun whenPlainTextMessageBodyIsLoadedThenPlainTextMessageBodyIsDisplayedInWebView() { + // given + val state = MessageDetailsPreviewData.Message + val messageBody = (state.messageBodyState as MessageBodyState.Data).messageBodyUiModel.messageBodyWithoutQuote + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageBodySection { + verify { messageInWebViewContains(messageBody) } + } + } + + @Test + fun whenHtmlMessageBodyIsLoadedThenHtmlMessageBodyIsDisplayedInWebView() { + // given + val state = MessageDetailsPreviewData.Message.copy( + messageBodyState = MessageBodyState.Data(MessageBodyUiModelTestData.htmlMessageBodyUiModel) + ) + val messageBody = """ + Dear Test, + This is an HTML message body. + Kind regards, + Developer + """.trimIndent() + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageBodySection { + verify { messageInWebViewContains(messageBody, tagName = "div") } + } + } + + @Test + fun whenMessageBodyLoadingFailedWithNoNetworkThenErrorMessageIsShown() { + // given + val state = MessageDetailsPreviewData.Message.copy( + messageBodyState = MessageBodyState.Error.Data(isNetworkError = true) + ) + val errorMessage = R.string.error_offline_loading_message + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageBodySection { + verify { loadingErrorMessageIsDisplayed(errorMessage) } + } + } + + @Test + fun whenMessageBodyLoadingFailedThenErrorMessageAndReloadButtonIsShown() { + // given + val state = MessageDetailsPreviewData.Message.copy( + messageBodyState = MessageBodyState.Error.Data(isNetworkError = false) + ) + val errorMessage = R.string.error_loading_message + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageBodySection { + verify { + loadingErrorMessageIsDisplayed(errorMessage) + bodyReloadButtonIsDisplayed() + } + } + } + + @Test + fun whenMessageBodyDecryptionFailedThenEncryptedBodyAndErrorMessageAreShown() { + // given + val state = MessageDetailsPreviewData.Message.copy( + messageBodyState = MessageBodyState.Error.Decryption(MessageBodyUiModelTestData.plainTextMessageBodyUiModel) + ) + val messageBody = (state.messageBodyState as MessageBodyState.Error.Decryption).encryptedMessageBody.messageBody + + // when + val robot = setUpScreen(state = state) + + // then + robot.messageBodySection { + verify { + bodyDecryptionErrorMessageIsDisplayed() + messageInWebViewContains(messageBody) + } + } + } + + @Test + fun whenMessageBodyLinkWasClickedThenCallbackIsInvoked() { + // Given + val uri = Uri.EMPTY + val state = MessageDetailsPreviewData.Message.copy( + openMessageBodyLinkEffect = Effect.of(uri) + ) + var isMessageBodyLinkOpened = false + + // When + setUpScreen( + state = state, + actions = MessageDetailScreen.Actions.Empty.copy( + onOpenMessageBodyLink = { isMessageBodyLinkOpened = true } + ) + ) + + // Then + assertTrue(isMessageBodyLinkOpened) + } + + private fun setUpScreen( + state: MessageDetailState, + actions: MessageDetailScreen.Actions = MessageDetailScreen.Actions.Empty + ) = messageDetailRobot { + this@MessageDetailScreenTest.composeTestRule.setContent { + MessageDetailScreen(state = state, actions = actions) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt new file mode 100644 index 0000000000..7a04357201 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxItemLabelsTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.mailbox + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import ch.protonmail.android.maillabel.presentation.model.LabelUiModel +import ch.protonmail.android.maillabel.presentation.previewdata.MailboxItemLabelsPreviewData +import ch.protonmail.android.maillabel.presentation.ui.LabelsList +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import kotlin.test.Test + +@RegressionTest +@HiltAndroidTest +internal class MailboxItemLabelsTest : HiltInstrumentedTest() { + + @Test + fun whenAllLabelsCanFitTheScreenShowsThemEntirely() { + + // given + val labels = MailboxItemLabelsPreviewData.ThreeItems + + // when + setupWithState(labels) + + // then + for (label in labels) { + composeTestRule.onNodeWithText(label.name) + .assertIsDisplayed() + } + } + + private fun setupWithState(labels: List) { + composeTestRule.setContent { + ProtonTheme { + LabelsList(labels = labels) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt new file mode 100644 index 0000000000..35f4c0bac4 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxScreenTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.mailbox + +import androidx.compose.runtime.Composable +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import arrow.core.nonEmptyListOf +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.ui.delete.DeleteDialogState +import ch.protonmail.android.maillabel.domain.model.MailLabel +import ch.protonmail.android.maillabel.domain.model.MailLabelId +import ch.protonmail.android.maillabel.presentation.sample.LabelUiModelSample +import ch.protonmail.android.maillabel.presentation.text +import ch.protonmail.android.mailmailbox.domain.model.MailboxItemType +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxScreen +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxItemUiModel +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxListState +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxState +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxTopAppBarState +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.StorageLimitState +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilterState +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UpgradeStorageState +import ch.protonmail.android.mailmailbox.presentation.mailbox.previewdata.MailboxSearchStateSampleData +import ch.protonmail.android.mailmailbox.presentation.mailbox.previewdata.MailboxStateSampleData +import ch.protonmail.android.mailnotifications.presentation.model.NotificationPermissionDialogState +import ch.protonmail.android.mailsettings.presentation.accountsettings.autodelete.AutoDeleteSettingState +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.testdata.mailbox.MailboxItemUiModelTestData +import ch.protonmail.android.uitest.models.avatar.AvatarInitial +import ch.protonmail.android.uitest.models.folders.MailLabelEntry +import ch.protonmail.android.uitest.models.mailbox.MailboxListItemEntry +import ch.protonmail.android.uitest.models.mailbox.ParticipantEntry +import ch.protonmail.android.uitest.robot.mailbox.MailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.mailboxRobot +import ch.protonmail.android.uitest.robot.mailbox.section.emptyListSection +import ch.protonmail.android.uitest.robot.mailbox.section.listSection +import ch.protonmail.android.uitest.robot.mailbox.section.progressListSection +import ch.protonmail.android.uitest.robot.mailbox.section.verify +import ch.protonmail.android.uitest.util.ManagedState +import ch.protonmail.android.uitest.util.StateManager +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf +import org.junit.Ignore +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +internal class MailboxScreenTest : HiltInstrumentedTest() { + + private val topMailboxItem = MailboxListItemEntry( + index = 0, + avatarInitial = AvatarInitial.WithText("T"), + participants = listOf(ParticipantEntry.NoSender), + subject = "1", + date = "10:42" + ) + + @Test + fun whenLoadingThenProgressIsDisplayed() { + val mailboxState = MailboxStateSampleData.Loading + val robot = setupScreen(state = mailboxState) + + robot.progressListSection { verify { isShown() } } + } + + @Test + fun whenLoadingCompletedThenItemsAreDisplayed() { + val mailboxListState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabel.System(MailLabelId.System.Inbox), + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + offlineEffect = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipingEnabled = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + clearState = MailboxListState.Data.ClearState.Hidden, + autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden + ) + val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState) + val items = listOf(MailboxItemUiModelTestData.readMailboxItemUiModel) + val robot = setupScreen(state = mailboxState, items = items) + + robot.listSection { + verify { + listItemsAreShown(topMailboxItem.copy(subject = items.first().subject)) + } + } + } + + @Test + fun whenLoadingCompletedThenItemsLabelsAreDisplayed() { + val mailboxListState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabel.System(MailLabelId.System.Inbox), + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + offlineEffect = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipingEnabled = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + clearState = MailboxListState.Data.ClearState.Hidden, + autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden + ) + val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState) + val label = LabelUiModelSample.News + val item = MailboxItemUiModelTestData.buildMailboxUiModelItem( + labels = persistentListOf(label) + ) + val mailboxItem = topMailboxItem.copy( + labels = listOf(MailLabelEntry(index = 0, name = label.name)), + subject = "0" + ) + + val robot = setupScreen(state = mailboxState, items = listOf(item)) + + robot.listSection { verify { listItemsAreShown(mailboxItem) } } + } + + @Test + @Ignore( + """ + The current version of the paging library doesn't allow us to test this in the same way. + Wee need to find an alternative + """ + ) // MAILANDR-330 + fun givenLoadingCompletedWhenNoItemThenEmptyMailboxIsDisplayed() { + val mailboxListState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabel.System(MailLabelId.System.Inbox), + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + offlineEffect = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipingEnabled = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + clearState = MailboxListState.Data.ClearState.Hidden, + autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden + ) + val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState) + val robot = setupScreen(state = mailboxState) + + robot.emptyListSection { verify { isShown() } } + } + + @Test + @Ignore("How to verify SwipeRefresh is refreshing?") // MAILANDR-330 + fun givenEmptyMailboxIsDisplayedWhenSwipeDownThenRefreshIsTriggered() { + val mailboxListState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabel.System(MailLabelId.System.Inbox), + openItemEffect = Effect.empty(), + scrollToMailboxTop = Effect.empty(), + offlineEffect = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipingEnabled = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + clearState = MailboxListState.Data.ClearState.Hidden, + autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden + ) + val mailboxState = MailboxStateSampleData.Loading.copy(mailboxListState = mailboxListState) + val robot = setupScreen(state = mailboxState) + + // TODO + } + + @Test + fun givenDataIsLoadedWhenCurrentLabelChangesThenScrollToTop() { + val items = (1..100).map { index -> + MailboxItemUiModelTestData.buildMailboxUiModelItem( + id = index.toString(), + type = MailboxItemType.Message + ) + } + val itemsFlow = flowOf(PagingData.from(items)) + val states = nonEmptyListOf( + MailLabelId.System.Trash to false, + MailLabelId.System.AllMail to true, + MailLabelId.System.Trash to true + ).map { (systemLabel, shouldScrollToTop) -> + val scrollToTopEffect: Effect = + if (shouldScrollToTop) Effect.of(systemLabel) else Effect.empty() + MailboxState( + mailboxListState = MailboxListState.Data.ViewMode( + currentMailLabel = MailLabel.System(systemLabel), + openItemEffect = Effect.empty(), + scrollToMailboxTop = scrollToTopEffect, + offlineEffect = Effect.empty(), + refreshErrorEffect = Effect.empty(), + refreshRequested = false, + swipingEnabled = false, + swipeActions = null, + searchState = MailboxSearchStateSampleData.NotSearching, + clearState = MailboxListState.Data.ClearState.Hidden, + autoDeleteBannerState = MailboxListState.Data.AutoDeleteBannerState.Hidden + ), + topAppBarState = MailboxTopAppBarState.Data.DefaultMode( + currentLabelName = MailLabel.System(systemLabel).text() + ), + upgradeStorageState = UpgradeStorageState(notificationDotVisible = false), + unreadFilterState = UnreadFilterState.Loading, + bottomAppBarState = BottomBarState.Data.Hidden(emptyList().toImmutableList()), + actionResult = Effect.empty(), + deleteDialogState = DeleteDialogState.Hidden, + deleteAllDialogState = DeleteDialogState.Hidden, + storageLimitState = StorageLimitState.HasEnoughSpace, + bottomSheetState = null, + error = Effect.empty(), + showRatingBooster = Effect.empty(), + autoDeleteSettingState = AutoDeleteSettingState.Loading + ) + } + + val stateManager = StateManager.of(states) + val robot = setupManagedState { + ManagedState(stateManager = stateManager) { mailboxState -> + MailboxScreen( + mailboxState = mailboxState, + mailboxListItems = itemsFlow.collectAsLazyPagingItems(), + actions = MailboxScreen.Actions.Empty + ) + } + } + + robot.listSection { + verify { listItemsAreShown(topMailboxItem) } + scrollToItemAtIndex(99) + + stateManager.emitNext() + + verify { listItemsAreShown(topMailboxItem) } + scrollToItemAtIndex(99) + + stateManager.emitNext() + + verify { listItemsAreShown(topMailboxItem) } + } + } + + private fun setupManagedState(content: @Composable () -> Unit): MailboxRobot = mailboxRobot { + this@MailboxScreenTest.composeTestRule.setContent(content) + } + + private fun setupScreen( + state: MailboxState = MailboxStateSampleData.Loading, + items: List = emptyList() + ): MailboxRobot = mailboxRobot { + this@MailboxScreenTest.composeTestRule.setContent { + val mailboxItems = flowOf(PagingData.from(items)).collectAsLazyPagingItems() + + MailboxScreen( + mailboxState = state, + mailboxListItems = mailboxItems, + actions = MailboxScreen.Actions.Empty + ) + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt new file mode 100644 index 0000000000..4d1698c365 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxTopAppBarTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.mailbox + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import ch.protonmail.android.R +import ch.protonmail.android.maillabel.domain.model.MailLabel +import ch.protonmail.android.maillabel.domain.model.MailLabelId +import ch.protonmail.android.maillabel.presentation.text +import ch.protonmail.android.mailmailbox.presentation.mailbox.MailboxTopAppBar +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.MailboxTopAppBarState.Data +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UpgradeStorageState +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.InstrumentationHolder +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test + +@Suppress("SameParameterValue") // We want test parameters to be explicit +@RegressionTest +@HiltAndroidTest +internal class MailboxTopAppBarTest : HiltInstrumentedTest() { + + private val context = InstrumentationHolder.instrumentation.targetContext + + @Test + fun hamburgerIconIsShownInDefaultMode() { + setupScreenWithDefaultMode(MAIL_LABEL_INBOX) + + composeTestRule + .onHamburgerIconButton() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun labelNameIsShownInDefaultMode() { + setupScreenWithDefaultMode(MAIL_LABEL_INBOX) + + composeTestRule + .onNodeWithText(MAIL_LABEL_INBOX_TEXT) + .assertIsDisplayed() + } + + @Test + fun actionsAreShownInDefaultMode() { + setupScreenWithDefaultMode(MAIL_LABEL_INBOX) + + composeTestRule + .onSearchIconButton() + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule + .onComposeIconButton() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun backIconIsShownInSelectionMode() { + setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN) + + composeTestRule + .onExitSelectionModeIconButton() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun correctCountIsShownInSelectionMode() { + setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN) + + composeTestRule + .onNodeWithText( + context.resources.getQuantityString( + R.plurals.mailbox_toolbar_selected_count, + SELECTED_COUNT_TEN, + SELECTED_COUNT_TEN + ) + ) + .assertIsDisplayed() + } + + @Test + fun actionsAreHiddenInSelectionMode() { + setupScreenWithSelectionMode(MAIL_LABEL_INBOX, selectedCount = SELECTED_COUNT_TEN) + + composeTestRule + .onSearchIconButton() + .assertDoesNotExist() + + composeTestRule + .onComposeIconButton() + .assertDoesNotExist() + } + + @Test + fun exitSearchModeButtonShownInSearchMode() { + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "query") + + composeTestRule + .onExitSearchIconButton() + .assertIsDisplayed() + .assertHasClickAction() + + } + + @Test + fun clearSearchQueryButtonShownInSearchModeWhenQueryEntered() { + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "query") + + composeTestRule + .onClearSearchQueryIconButton() + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun clearSearchQueryButtonHiddenInSearchModeWhenQueryIsEmpty() { + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "") + + composeTestRule + .onClearSearchQueryIconButton() + .assertDoesNotExist() + } + + @Test + fun searchTextFieldShouldHaveFocusWhenSearchModeStarted() { + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "") + + composeTestRule + .onNodeWithText("") + .assertIsDisplayed() + .assertIsFocused() + } + + @Test + fun searchTextFieldShouldNotHaveFocusWhenSearchModeStartedWithExistingQuery() { + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "search query") + + composeTestRule + .onNodeWithText("search query") + .assertIsDisplayed() + .assertIsNotFocused() + } + + @Test + fun clearSearchQueryButtonClearsSearchTextField() { + // Given + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "non-empty query") + composeTestRule + .onNodeWithText("non-empty query") + .assertIsDisplayed() + + // When + composeTestRule + .onClearSearchQueryIconButton() + .performClick() + + // Then + composeTestRule + .onNodeWithText("") + .assertIsDisplayed() + } + + @Test + fun clearSearchQueryButtonBecomeVisibleAfterEnteringQuery() { + // Given + setupScreenWithSearchMode(MAIL_LABEL_INBOX, searchQuery = "") + composeTestRule + .onClearSearchQueryIconButton() + .assertDoesNotExist() + + // When + composeTestRule + .onNodeWithText("") + .performTextInput("some query") + + // Then + composeTestRule + .onClearSearchQueryIconButton() + .assertIsDisplayed() + } + + private fun setupScreenWithState(state: Data) { + composeTestRule.setContent { + ProtonTheme { + MailboxTopAppBar( + state = state, + upgradeStorageState = UpgradeStorageState(notificationDotVisible = false), + actions = MailboxTopAppBar.Actions( + onOpenMenu = {}, + onExitSelectionMode = {}, + onExitSearchMode = {}, + onTitleClick = {}, + onEnterSearchMode = {}, + onSearch = {}, + onOpenComposer = {}, + onNavigateToStandaloneUpselling = {}, + onOpenUpsellingPage = {}, + onCloseUpsellingPage = {} + ) + ) + } + } + + composeTestRule.waitForIdle() + } + + private fun setupScreenWithDefaultMode(currentMailLabel: MailLabel) { + val state = Data.DefaultMode(currentLabelName = currentMailLabel.text()) + setupScreenWithState(state) + } + + private fun setupScreenWithSelectionMode(currentMailLabel: MailLabel, selectedCount: Int) { + val state = Data.SelectionMode( + currentLabelName = currentMailLabel.text(), + selectedCount = selectedCount + ) + setupScreenWithState(state) + } + + private fun setupScreenWithSearchMode(currentMailLabel: MailLabel, searchQuery: String) { + val state = Data.SearchMode( + currentLabelName = currentMailLabel.text(), + searchQuery = searchQuery + ) + setupScreenWithState(state) + } + + private fun SemanticsNodeInteractionsProvider.onHamburgerIconButton() = + onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_menu_button_content_description)) + + private fun SemanticsNodeInteractionsProvider.onExitSelectionModeIconButton() = onNodeWithContentDescription( + context.getString(R.string.mailbox_toolbar_exit_selection_mode_button_content_description) + ) + + private fun SemanticsNodeInteractionsProvider.onSearchIconButton() = + onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_search_button_content_description)) + + private fun SemanticsNodeInteractionsProvider.onComposeIconButton() = + onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_compose_button_content_description)) + + private fun SemanticsNodeInteractionsProvider.onExitSearchIconButton() = + onNodeWithContentDescription(context.getString(R.string.mailbox_toolbar_exit_search_mode_content_description)) + + private fun SemanticsNodeInteractionsProvider.onClearSearchQueryIconButton() = onNodeWithContentDescription( + context.getString(R.string.mailbox_toolbar_searchview_clear_search_query_content_description) + ) + + private companion object TestData { + + val MAIL_LABEL_INBOX = MailLabel.System(MailLabelId.System.Inbox) + const val MAIL_LABEL_INBOX_TEXT = "Inbox" + const val SELECTED_COUNT_TEN = 10 + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt new file mode 100644 index 0000000000..16b71f1c69 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/mailbox/MailboxUnreadFiltersTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.mailbox + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilter +import ch.protonmail.android.mailmailbox.presentation.mailbox.UnreadItemsFilterTestTags +import ch.protonmail.android.mailmailbox.presentation.mailbox.model.UnreadFilterState +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +internal class MailboxUnreadFiltersTest : HiltInstrumentedTest() { + + private val unreadFilterNode by lazy { + composeTestRule + .onNodeWithTag(UnreadItemsFilterTestTags.UnreadFilterChip) + } + + @Test + fun unreadFilterItemDisplaysZeroCounterWhenZero() { + // When + setupCountersItem(CounterZeroValue) + + // Then + unreadFilterNode.assertTextEquals(CounterZeroValueText) + } + + @Test + fun unreadFilterItemDisplaysCounterValueWhenBelowThreshold() { + // When + setupCountersItem(CounterValue) + + // Then + unreadFilterNode.assertTextEquals(CounterValueText) + } + + @Test + fun unreadFilterItemDisplaysCappedCounterValueWhenAboveThreshold() { + // When + setupCountersItem(CounterValueAboveThreshold) + + // Then + unreadFilterNode.assertTextEquals(CounterValueTextCapped) + } + + private fun setupCountersItem(count: Int) { + composeTestRule.setContent { + ProtonTheme { + UnreadItemsFilter( + state = UnreadFilterState.Data( + numUnread = count, + isFilterEnabled = false + ), + onFilterDisabled = {}, + onFilterEnabled = {} + ) + } + } + } + + private companion object { + + const val CounterZeroValue = 0 + const val CounterZeroValueText = "$CounterZeroValue unread" + const val CounterValue = 10 + const val CounterValueText = "$CounterValue unread" + const val CounterValueAboveThreshold = 10_000 + const val CounterValueTextCapped = "9999+ unread" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt new file mode 100644 index 0000000000..cd844c4572 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/AccountSettingsScreenTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.account + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.AccountSettingsState.Data +import ch.protonmail.android.mailsettings.presentation.accountsettings.AutoDeleteSettingsState +import ch.protonmail.android.mailsettings.presentation.accountsettings.TEST_TAG_ACCOUNT_SETTINGS_LIST +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.assertions.assertTextContains +import ch.protonmail.android.uitest.util.hasText +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.accountmanager.presentation.compose.R.string as CoreString +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Before +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +internal class AccountSettingsScreenTest : HiltInstrumentedTest() { + + private val settingsState = Data( + recoveryEmail = "recovery-email@protonmail.com", + mailboxSize = 20_000, + mailboxUsedSpace = 15_000, + defaultEmail = "contact@protonmail.ch", + isConversationMode = true, + registeredSecurityKeys = emptyList(), + securityKeysVisible = true, + autoDeleteSettingsState = AutoDeleteSettingsState(isSettingVisible = true) + ) + + @Before + fun setUp() { + composeTestRule.setContent { + ProtonTheme { + AccountSettingScreen( + state = settingsState, + actions = AccountSettingScreen.Actions( + onBackClick = {}, + onPasswordManagementClick = {}, + onRecoveryEmailClick = {}, + onSecurityKeysClick = {}, + onConversationModeClick = {}, + onDefaultEmailAddressClick = {}, + onDisplayNameClick = {}, + onPrivacyClick = {}, + onLabelsClick = {}, + onFoldersClick = {}, + onAutoDeleteClick = {} + ) + ) + } + } + } + + @Test + fun testAccountSettingsScreenContainsAllExpectedSections() { + composeTestRule.onNodeWithText(string.mail_settings_account).assertIsDisplayed() + composeTestRule.onNodeWithText(string.mail_settings_addresses).assertIsDisplayed() + composeTestRule.onNodeWithText(string.mail_settings_mailbox).assertIsDisplayed() + } + + @Test + fun testAccountSettingsScreenDisplayStateCorrectly() { + composeTestRule + .onNodeWithText(string.mail_settings_recovery_email) + .assertTextContains("recovery-email@protonmail.com") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_password_management) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_recovery_email) + .assertTextContains("recovery-email@protonmail.com") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(CoreString.account_settings_list_item_security_keys_header) + .assertTextContains("Not set") + .assertIsDisplayed() + + // Assert values individually as android's `Formatter.formatShortFileSize` method + // adds many non-printable BiDi chars when executing on some virtual devices + // so checking for "1 kB / 2 kB" would not find a match + composeTestRule + .onNodeWithText(string.mail_settings_mailbox_size) + .assertTextContains(value = "15", substring = true) + .assertTextContains(value = "20", substring = true) + .assertTextContains(value = "kB", substring = true, ignoreCase = true) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_conversation_mode) + .assertTextContains(string.mail_settings_enabled) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_auto_delete) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_default_email_address) + .assertTextContains("contact@protonmail.ch") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_display_name_and_signature) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST) + .onChild() + .performScrollToNode(hasText(string.mail_settings_privacy)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST) + .onChild() + .performScrollToNode(hasText(string.mail_settings_labels)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(TEST_TAG_ACCOUNT_SETTINGS_LIST) + .onChild() + .performScrollToNode(hasText(string.mail_settings_folders)) + .assertIsDisplayed() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt new file mode 100644 index 0000000000..71912ec737 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/account/conversationmode/ConversationModeSettingScreenTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.account.conversationmode + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isToggleable +import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingScreen +import ch.protonmail.android.mailsettings.presentation.accountsettings.conversationmode.ConversationModeSettingState.Data +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test + +@RegressionTest +@HiltAndroidTest +internal class ConversationModeSettingScreenTest : HiltInstrumentedTest() { + + @Test + fun testConversationModeToggleIsOnWhenStateIsTrue() { + setupScreenWithState(Data(true)) + + composeTestRule + .onNode(isToggleable()) + .assertIsDisplayed() + .assertIsEnabled() + .assertIsOn() + } + + @Test + fun testConversationModeToggleIsOffWhenStateIsFalse() { + setupScreenWithState(Data(false)) + + composeTestRule + .onNode(isToggleable()) + .assertIsDisplayed() + .assertIsEnabled() + .assertIsOff() + } + + @Test + fun testConversationModeToggleIsOffWhenStateIsInvalid() { + setupScreenWithState(Data(null)) + + composeTestRule + .onNode(isToggleable()) + .assertIsDisplayed() + .assertIsOff() + } + + private fun setupScreenWithState(state: Data) { + composeTestRule.setContent { + ProtonTheme { + ConversationModeSettingScreen( + onBackClick = { }, + onConversationModeToggled = { }, + state = state + ) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt new file mode 100644 index 0000000000..fca906127c --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/SettingsScreenTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings + +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailcommon.domain.AppInformation +import ch.protonmail.android.mailsettings.domain.model.AppSettings +import ch.protonmail.android.mailsettings.domain.model.LocalStorageUsageInformation +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.settings.AccountInfo +import ch.protonmail.android.mailsettings.presentation.settings.MainSettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.SettingsScreenTestTags +import ch.protonmail.android.mailsettings.presentation.settings.SettingsState.Data +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.assertions.assertTextContains +import ch.protonmail.android.uitest.util.hasText +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Before +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +internal class SettingsScreenTest : HiltInstrumentedTest() { + + private val settingsState = Data( + AccountInfo("ProtonTest", "user-test@proton.ch"), + AppSettings( + hasAutoLock = false, + hasAlternativeRouting = true, + customAppLanguage = null, + hasCombinedContacts = true + ), + AppInformation(appVersionName = "6.0.0-alpha-adf8373a", appVersionCode = 9026), + LocalStorageUsageInformation(123L) + ) + + @Before + fun setUp() { + composeTestRule.setContent { + ProtonTheme { + MainSettingsScreen( + state = settingsState, + actions = MainSettingsScreen.Actions( + onAccountClick = {}, + onThemeClick = {}, + onPushNotificationsClick = {}, + onAutoLockClick = {}, + onAlternativeRoutingClick = {}, + onAppLanguageClick = {}, + onCombinedContactsClick = {}, + onSwipeActionsClick = {}, + onClearCacheClick = {}, + onBackClick = {}, + onExportLogsClick = {}, + onCustomizeToolbarClick = {}, + onSignOut = {} + ), + LogsExportFeatureSetting(enabled = false, internalEnabled = false) + ) + } + } + } + + @Test + fun testSettingsScreenContainsAllExpectedSections() { + composeTestRule.onNodeWithText(string.mail_settings_account_settings).assertIsDisplayed() + composeTestRule.onNodeWithText(string.mail_settings_app_settings).assertIsDisplayed() + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_app_information)) + .assertIsDisplayed() + } + + @Test + fun testSettingsScreenDisplayStateCorrectly() { + composeTestRule + .onNodeWithText("ProtonTest") + .assertTextContains("user-test@proton.ch") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_theme) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_push_notifications) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_auto_lock) + .assertTextContains(string.mail_settings_disabled) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_alternative_routing) + .assertTextContains(string.mail_settings_allowed) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_app_language) + .assertTextContains(string.mail_settings_auto_detect) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(string.mail_settings_combined_contacts) + .assertTextContains(string.mail_settings_enabled) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_local_cache)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_customize_toolbar)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_swipe_actions)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_app_version)) + + composeTestRule + .onNodeWithText("6.0.0-alpha-adf8373a (9026)") + .assertHasNoClickAction() + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(SettingsScreenTestTags.SettingsList) + .onChild() + .performScrollToNode(hasText(string.mail_settings_app_version)) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt new file mode 100644 index 0000000000..2def755f49 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/alternativerouting/AlternativeRoutingSettingScreenTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings.alternativerouting + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingScreen +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.AlternativeRoutingSettingState +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR +import ch.protonmail.android.mailsettings.presentation.settings.alternativerouting.TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test +import kotlin.test.assertEquals + +@RegressionTest +@HiltAndroidTest +internal class AlternativeRoutingSettingScreenTest : HiltInstrumentedTest() { + + @Test + fun testProgressIsShownWhenStateIsLoading() { + setupScreenWithState(AlternativeRoutingSettingState.Loading) + + composeTestRule + .onNodeWithTag(PROTON_PROGRESS_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun testSwitchIsCheckedIfAlternativeRoutingSettingIsEnabled() { + setupScreenWithState( + AlternativeRoutingSettingState.Data(isEnabled = true, alternativeRoutingSettingErrorEffect = Effect.empty()) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .assertIsOn() + } + + @Test + fun testSwitchIsNotCheckedIfAlternativeRoutingSettingIsNotEnabled() { + setupScreenWithState( + AlternativeRoutingSettingState.Data( + isEnabled = false, + alternativeRoutingSettingErrorEffect = Effect.empty() + ) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .assertIsOff() + } + + @Test + fun testCallbackIsInvokedWhenSwitchIsToggled() { + var isEnabled = false + setupScreenWithState( + state = AlternativeRoutingSettingState.Data( + isEnabled = false, + alternativeRoutingSettingErrorEffect = Effect.empty() + ), + onToggle = { isEnabled = !isEnabled } + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .performClick() + + assertEquals(true, isEnabled) + } + + @Test + fun testErrorSnackbarIsShownWhenStateContainsThrowableEffect() { + setupScreenWithState( + AlternativeRoutingSettingState.Data( + isEnabled = false, + alternativeRoutingSettingErrorEffect = Effect.of(Unit) + ) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR) + .assertIsDisplayed() + } + + @Test + fun testSwitchIsOffAndSnackbarIsShownWhenSwitchStateIsNull() { + setupScreenWithState( + AlternativeRoutingSettingState.Data( + isEnabled = null, + alternativeRoutingSettingErrorEffect = Effect.of(Unit) + ) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_TOGGLE_ITEM) + .assertIsDisplayed() + .assertIsOff() + composeTestRule + .onNodeWithTag(TEST_TAG_ALTERNATIVE_ROUTING_SNACKBAR) + .assertIsDisplayed() + } + + private fun setupScreenWithState( + state: AlternativeRoutingSettingState, + onBackClick: () -> Unit = {}, + onToggle: (Boolean) -> Unit = {} + ) { + composeTestRule.setContent { + ProtonTheme { + when (state) { + is AlternativeRoutingSettingState.Data -> { + AlternativeRoutingSettingScreen( + onBackClick = onBackClick, + onToggle = onToggle, + state = state + ) + } + is AlternativeRoutingSettingState.Loading -> { + ProtonCenteredProgress(Modifier.fillMaxWidth()) + } + } + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt new file mode 100644 index 0000000000..6e5f1542dd --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/combinedcontacts/CombinedContactsSettingScreenTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings.combinedcontacts + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingScreen +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.CombinedContactsSettingState +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_SNACKBAR +import ch.protonmail.android.mailsettings.presentation.settings.combinedcontacts.TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test +import kotlin.test.assertEquals + +@RegressionTest +@HiltAndroidTest +internal class CombinedContactsSettingScreenTest : HiltInstrumentedTest() { + + @Test + fun testSwitchIsCheckedIfCombinedContactsSettingIsEnabled() { + setupScreenWithState( + CombinedContactsSettingState.Data(isEnabled = true, combinedContactsSettingErrorEffect = Effect.empty()) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .assertIsOn() + } + + @Test + fun testSwitchIsNotCheckedIfCombinedContactsSettingIsNotEnabled() { + setupScreenWithState( + CombinedContactsSettingState.Data(isEnabled = false, combinedContactsSettingErrorEffect = Effect.empty()) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .assertIsOff() + } + + @Test + fun testCallbackIsInvokedWhenSwitchIsToggled() { + var isEnabled = false + setupScreenWithState( + state = CombinedContactsSettingState.Data( + isEnabled = false, + combinedContactsSettingErrorEffect = Effect.empty() + ), + onToggle = { isEnabled = !isEnabled } + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .performClick() + + assertEquals(true, isEnabled) + } + + @Test + fun testErrorSnackbarIsShownWhenStateContainsThrowableEffect() { + setupScreenWithState( + CombinedContactsSettingState.Data( + isEnabled = false, + combinedContactsSettingErrorEffect = Effect.of(Unit) + ) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_SNACKBAR) + .assertIsDisplayed() + } + + @Test + fun testSwitchIsOffAndSnackbarIsShownWhenSwitchStateIsNull() { + setupScreenWithState( + CombinedContactsSettingState.Data( + isEnabled = null, + combinedContactsSettingErrorEffect = Effect.of(Unit) + ) + ) + + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_TOGGLE_ITEM) + .assertIsDisplayed() + .assertIsOff() + composeTestRule + .onNodeWithTag(TEST_TAG_COMBINED_CONTACTS_SNACKBAR) + .assertIsDisplayed() + } + + private fun setupScreenWithState( + state: CombinedContactsSettingState.Data, + onBackClick: () -> Unit = {}, + onToggle: (Boolean) -> Unit = {} + ) { + composeTestRule.setContent { + ProtonTheme { + CombinedContactsSettingScreen( + onBackClick = onBackClick, + onToggle = onToggle, + state = state + ) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt new file mode 100644 index 0000000000..4c3027ae88 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/EditSwipeActionPreferenceScreenTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings.swipeactions + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.mailsettings.domain.model.SwipeActionDirection +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceScreen +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.EditSwipeActionPreferenceState +import ch.protonmail.android.mailsettings.presentation.testdata.SwipeActionsTestData +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.mailsettings.domain.entity.SwipeAction +import org.junit.Test +import ch.protonmail.android.mailsettings.presentation.R.string as settingsString + +@RegressionTest +@HiltAndroidTest +internal class EditSwipeActionPreferenceScreenTest : HiltInstrumentedTest() { + + @Test + fun whenRightSwipeIsSelectedCorrectTitleIsShown() { + // when + setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.RIGHT) + + // then + composeTestRule.onNodeWithText(settingsString.mail_settings_swipe_right_name) + .assertIsDisplayed() + } + + @Test + fun whenLeftSwipeIsSelectedCorrectTitleIsShown() { + // when + setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.LEFT) + + // then + composeTestRule.onNodeWithText(settingsString.mail_settings_swipe_left_name) + .assertIsDisplayed() + } + + @Test + fun whileDataIsLoadingProgressIsShown() { + // when + setContentWithState(EditSwipeActionPreferenceState.Loading, direction = SwipeActionDirection.LEFT) + + // then + composeTestRule.onNodeWithTag(PROTON_PROGRESS_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun whenDataIsReadyCorrectItemIsSelected() { + // when + val items = SwipeActionsTestData.Edit.buildAllItems(selected = SwipeAction.Star) + setContentWithState(EditSwipeActionPreferenceState.Data(items), direction = SwipeActionDirection.LEFT) + + // then + composeTestRule.onArchive() + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule.onRead() + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule.onSpam() + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule.onStar() + .assertIsDisplayed() + .assertIsSelected() + + composeTestRule.onTrash() + .assertIsDisplayed() + .assertIsNotSelected() + } + + private fun setContentWithState(state: EditSwipeActionPreferenceState, direction: SwipeActionDirection) { + composeTestRule.setContent { + ProtonTheme { + EditSwipeActionPreferenceScreen( + state = state, + direction = direction, + onBack = {}, + onSwipeActionSelect = {} + ) + } + } + } + + private fun ComposeTestRule.onArchive(): SemanticsNodeInteraction = + onNodeWithText(settingsString.mail_settings_swipe_action_archive_description) + + private fun ComposeTestRule.onRead(): SemanticsNodeInteraction = + onNodeWithText(settingsString.mail_settings_swipe_action_read_description) + + private fun ComposeTestRule.onSpam(): SemanticsNodeInteraction = + onNodeWithText(settingsString.mail_settings_swipe_action_spam_description) + + private fun ComposeTestRule.onStar(): SemanticsNodeInteraction = + onNodeWithText(settingsString.mail_settings_swipe_action_star_description) + + private fun ComposeTestRule.onTrash(): SemanticsNodeInteraction = + onNodeWithText(settingsString.mail_settings_swipe_action_trash_description) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt new file mode 100644 index 0000000000..d4f3064da7 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/swipeactions/SwipeActionsPreferenceScreenTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings.swipeactions + +import androidx.compose.ui.test.assertAny +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onParent +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionPreferenceUiModel +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceScreen +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceState +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceState.Loading +import ch.protonmail.android.mailsettings.presentation.settings.swipeactions.SwipeActionsPreferenceUiModel +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.hasText +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG +import me.proton.core.compose.theme.ProtonTheme +import kotlin.test.Test +import ch.protonmail.android.mailsettings.presentation.R as SettingsR +import me.proton.core.presentation.R as CoreR + +@RegressionTest +@HiltAndroidTest +internal class SwipeActionsPreferenceScreenTest : HiltInstrumentedTest() { + + @Test + fun progressIsShownWhileDataLoading() { + setContentWithState(Loading) + + composeTestRule + .onNodeWithTag(PROTON_PROGRESS_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun correctActionsAreShown() { + setContentWithState(swipeActionsData) + + composeTestRule + .onNodeWithText(SettingsR.string.mail_settings_swipe_right_name) + .onParent() + .onChildren() + .assertAny(hasText(swipeActionsData.model.right.titleRes)) + .assertAny(hasText(swipeActionsData.model.right.descriptionRes)) + + composeTestRule + .onNodeWithText(SettingsR.string.mail_settings_swipe_left_name) + .onParent() + .onChildren() + .assertAny(hasText(swipeActionsData.model.left.titleRes)) + .assertAny(hasText(swipeActionsData.model.left.descriptionRes)) + } + + private fun setContentWithState(state: SwipeActionsPreferenceState) { + composeTestRule.setContent { + ProtonTheme { + SwipeActionsPreferenceScreen( + state = state, + actions = SwipeActionsPreferenceScreen.Actions( + onBackClick = {}, + onChangeSwipeRightClick = {}, + onChangeSwipeLeftClick = {} + ) + ) + } + } + } + + private companion object TestData { + + private val swipeActionsData = SwipeActionsPreferenceState.Data( + SwipeActionsPreferenceUiModel( + left = SwipeActionPreferenceUiModel( + imageRes = CoreR.drawable.ic_proton_archive_box, + titleRes = SettingsR.string.mail_settings_swipe_action_archive_title, + descriptionRes = SettingsR.string.mail_settings_swipe_action_archive_description, + getColor = { ProtonTheme.colors.iconHint } + ), + right = SwipeActionPreferenceUiModel( + imageRes = CoreR.drawable.ic_proton_trash, + titleRes = SettingsR.string.mail_settings_swipe_action_trash_title, + descriptionRes = SettingsR.string.mail_settings_swipe_action_trash_description, + getColor = { ProtonTheme.colors.notificationError } + ) + ) + ) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt new file mode 100644 index 0000000000..2233fcefb3 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/settings/appsettings/theme/ThemeSettingScreenTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.settings.appsettings.theme + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.performClick +import ch.protonmail.android.mailsettings.domain.model.Theme +import ch.protonmail.android.mailsettings.domain.model.Theme.DARK +import ch.protonmail.android.mailsettings.domain.model.Theme.LIGHT +import ch.protonmail.android.mailsettings.domain.model.Theme.SYSTEM_DEFAULT +import ch.protonmail.android.mailsettings.presentation.R.string +import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsScreen +import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeSettingsState.Data +import ch.protonmail.android.mailsettings.presentation.settings.theme.ThemeUiModel +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RegressionTest +@HiltAndroidTest +internal class ThemeSettingScreenTest : HiltInstrumentedTest() { + + @Test + fun testOnlySystemDefaultIsSelectedWhenThemeIsSystemDefault() { + setupScreenWithSystemDefaultTheme() + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_light) + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .assertIsDisplayed() + .assertIsNotSelected() + } + + @Test + fun testLightIsSelectedWhenThemeIsLight() { + setupScreenWithLightTheme() + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_light) + .assertIsDisplayed() + .assertIsSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .assertIsDisplayed() + .assertIsNotSelected() + } + + @Test + fun testDarkIsSelectedWhenThemeIsDark() { + setupScreenWithDarkTheme() + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_light) + .assertIsDisplayed() + .assertIsNotSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .assertIsDisplayed() + .assertIsSelected() + } + + @Test + fun testCallbackIsInvokedWithThemeIdWhenAThemeIsSelected() { + var selectedTheme: Theme? = null + setupScreenWithSystemDefaultTheme { + selectedTheme = it + } + assertNull(selectedTheme) + + composeTestRule + .onNodeWithText(string.mail_settings_system_default) + .assertIsDisplayed() + .assertIsSelected() + + composeTestRule + .onNodeWithText(string.mail_settings_theme_dark) + .performClick() + + assertEquals(DARK, selectedTheme) + } + + private fun setupScreenWithLightTheme() { + setupScreenWithState( + Data( + buildThemesList(isSystemDefault = false, isLight = true, isDark = false) + ) + ) + } + + private fun setupScreenWithDarkTheme() { + setupScreenWithState( + Data( + buildThemesList(isSystemDefault = false, isLight = false, isDark = true) + ) + ) + } + + private fun setupScreenWithSystemDefaultTheme(onThemeSelected: (Theme) -> Unit = {}) { + setupScreenWithState( + Data( + buildThemesList(isSystemDefault = true, isLight = false, isDark = false) + ), + onThemeSelected + ) + } + + private fun buildThemesList( + isSystemDefault: Boolean, + isLight: Boolean, + isDark: Boolean + ) = listOf( + ThemeUiModel(SYSTEM_DEFAULT, string.mail_settings_system_default, isSystemDefault), + ThemeUiModel(LIGHT, string.mail_settings_theme_light, isLight), + ThemeUiModel(DARK, string.mail_settings_theme_dark, isDark) + ) + + private fun setupScreenWithState( + state: Data, + onThemeSelected: (Theme) -> Unit = {} + ) { + composeTestRule.setContent { + ProtonTheme { + ThemeSettingsScreen( + onBackClick = { }, + onThemeSelected = onThemeSelected, + state = state + ) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt new file mode 100644 index 0000000000..66ca2a85a8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarScreenTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.sidebar + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.dp +import ch.protonmail.android.mailcommon.domain.AppInformation +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.maillabel.domain.model.MailLabelId +import ch.protonmail.android.maillabel.presentation.MailLabelUiModel +import ch.protonmail.android.maillabel.presentation.MailLabelsUiModel +import ch.protonmail.android.maillabel.presentation.R +import ch.protonmail.android.mailsidebar.presentation.Sidebar +import ch.protonmail.android.mailsidebar.presentation.SidebarMenuTestTags +import ch.protonmail.android.mailsidebar.presentation.SidebarState +import ch.protonmail.android.test.annotations.suite.RegressionTest +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.onNodeWithText +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.label.domain.entity.LabelId +import org.junit.Test +import ch.protonmail.android.maillabel.R as label +import me.proton.core.presentation.compose.R as core + +private const val APP_VERSION_FOOTER = "Proton Mail 6.0.0-alpha+test" + +@RegressionTest +@HiltAndroidTest +internal class SidebarScreenTest : HiltInstrumentedTest() { + + @Test + fun subscriptionIsShownWhenSidebarStateIsDisplaySubscription() { + setupScreenWithState(showSubscriptionSidebarState()) + + scrollToSidebarBottom() + + composeTestRule + .onNodeWithText(core.string.presentation_menu_item_title_subscription) + .assertIsDisplayed() + } + + @Test + fun subscriptionIsHiddenWhenSidebarStateIsHideSubscription() { + setupScreenWithState(hideSubscriptionSidebarState()) + + scrollToSidebarBottom() + composeTestRule + .onNodeWithText(core.string.presentation_menu_item_title_subscription) + .assertDoesNotExist() + } + + @Test + fun labelsAreOnlyDisplayingTitleEmptyItemsAndAddItem() { + setupScreenWithState(emptyLabelsSidebarState()) + + listOf( + label.string.label_title_labels, + label.string.label_title_folders, + label.string.label_title_create_folder, + label.string.label_title_create_label + ).forEach { + composeTestRule + .onNodeWithText(it) + .assertIsDisplayed() + } + } + + @Test + fun labelsAndFoldersAreDisplayed() { + setupScreenWithState(someLabelsSidebarState()) + + listOf( + "Folder1", + "Folder2", + "Folder3", + "Label1", + "Label2", + "Label3" + ).forEach { + composeTestRule + .onNodeWithText(it) + .assertIsDisplayed() + } + } + + private fun scrollToSidebarBottom(): SemanticsNodeInteraction { + return composeTestRule + .onNodeWithTag(SidebarMenuTestTags.Root) + .onChild() + .performScrollToNode(hasText(APP_VERSION_FOOTER, true)) + } + + private fun showSubscriptionSidebarState() = buildSidebarState(isSubscriptionVisible = true) + private fun hideSubscriptionSidebarState() = buildSidebarState(isSubscriptionVisible = false) + private fun emptyLabelsSidebarState() = buildSidebarState(mailLabels = MailLabelsUiModel.Loading) + private fun someLabelsSidebarState() = buildSidebarState( + mailLabels = MailLabelsUiModel( + systems = emptyList(), + folders = listOf( + buildMailLabelFolderUiModel("Folder1"), + buildMailLabelFolderUiModel("Folder2"), + buildMailLabelFolderUiModel("Folder3") + ), + labels = listOf( + buildMailLabelLabelUiModel("Label1"), + buildMailLabelLabelUiModel("Label2"), + buildMailLabelLabelUiModel("Label3") + ) + ) + ) + + private fun buildMailLabelFolderUiModel(text: String) = MailLabelUiModel.Custom( + id = MailLabelId.Custom.Folder(LabelId(text)), + key = text, + text = TextUiModel.Text(text), + icon = R.drawable.ic_proton_folder_filled, + iconTint = Color(0), + isSelected = false, + count = 0, + isVisible = true, + isExpanded = false, + iconPaddingStart = 0.dp + ) + + private fun buildMailLabelLabelUiModel(text: String) = MailLabelUiModel.Custom( + id = MailLabelId.Custom.Label(LabelId(text)), + key = text, + text = TextUiModel.Text(text), + icon = R.drawable.ic_proton_circle_filled, + iconTint = Color(0), + isSelected = false, + count = 0, + isVisible = true, + isExpanded = false, + iconPaddingStart = 0.dp + ) + + private fun buildSidebarState( + isSubscriptionVisible: Boolean = true, + mailLabels: MailLabelsUiModel = MailLabelsUiModel.Loading + ) = SidebarState( + isSubscriptionVisible = isSubscriptionVisible, + hasPrimaryAccount = false, + appInformation = AppInformation( + appName = "Proton Mail", + appVersionName = "6.0.0-alpha+test" + ), + mailLabels = mailLabels + ) + + private fun setupScreenWithState(state: SidebarState) { + composeTestRule.setContent { + ProtonTheme { + Sidebar(viewState = state, actions = Sidebar.Actions.Empty) + } + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt new file mode 100644 index 0000000000..27b7afbd81 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/sidebar/SidebarWithCounterItemTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.sidebar + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import ch.protonmail.android.maillabel.presentation.MailLabelUiModel +import ch.protonmail.android.maillabel.presentation.sidebar.SidebarItemWithCounterTestTags +import ch.protonmail.android.maillabel.presentation.sidebar.sidebarLabelItems +import ch.protonmail.android.maillabel.presentation.sidebar.sidebarSystemLabelItems +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.testdata.maillabel.MailLabelUiModelTestData +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.component.ProtonSidebarLazy +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +internal class SidebarWithCounterItemTest : HiltInstrumentedTest() { + + private val countersNode by lazy { + composeTestRule.onNodeWithTag(SidebarItemWithCounterTestTags.Counter, useUnmergedTree = true) + } + + @Test + fun sidebarSystemLabelCounterDisplaysValueWhenAvailable() { + // Given + val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = CounterValue) + + // When + setupLabelItem(systemFolder) + + // Then + countersNode.assertTextEquals(CounterValueText) + } + + @Test + fun sidebarSystemLabelItemDisplaysNoCounterValueWhenNull() { + // Given + val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = null) + + // When + setupLabelItem(systemFolder) + + // Then + countersNode.assertDoesNotExist() + } + + @Test + fun sidebarSystemLabelItemDisplaysCappedCounterValueWhenAboveThreshold() { + // Given + val systemFolder = MailLabelUiModelTestData.spamFolder.copy(count = CounterValueAboveThreshold) + + // When + setupLabelItem(systemFolder) + + // Then + countersNode.assertTextEquals(CounterValueTextCapped) + } + + @Test + fun sidebarCustomLabelCounterDisplaysValueWhenAvailable() { + // Given + val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = CounterValue) + + // When + setupLabelItem(customLabel) + + // Then + countersNode.assertTextEquals(CounterValueText) + } + + @Test + fun sidebarCustomLabelItemDisplaysNoCounterValueWhenNull() { + // Given + val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = null) + + // When + setupLabelItem(customLabel) + + // Then + countersNode.assertDoesNotExist() + } + + @Test + fun sidebarCustomLabelItemDisplaysCappedCounterValueWhenAboveThreshold() { + // Given + val customLabel = MailLabelUiModelTestData.customLabelList.first().copy(count = CounterValueAboveThreshold) + + // When + setupLabelItem(customLabel) + + // Then + countersNode.assertTextEquals(CounterValueTextCapped) + } + + private fun setupLabelItem(item: MailLabelUiModel) { + composeTestRule.setContent { + ProtonTheme { + ProtonSidebarLazy { + when (item) { + is MailLabelUiModel.Custom -> sidebarLabelItems(listOf(item)) {} + is MailLabelUiModel.System -> sidebarSystemLabelItems(listOf(item)) {} + } + } + } + } + } + + private companion object { + + const val CounterValue = 10 + const val CounterValueText = CounterValue.toString() + const val CounterValueAboveThreshold = 10_000 + const val CounterValueTextCapped = "9999+" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt new file mode 100644 index 0000000000..d444e07c48 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/screen/snackbar/DismissableSnackbarHostTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.screen.snackbar + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.rememberScaffoldState +import androidx.compose.material.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import ch.protonmail.android.test.annotations.suite.SmokeTest +import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost +import ch.protonmail.android.uitest.util.HiltInstrumentedTest +import ch.protonmail.android.uitest.util.awaitDisplayed +import ch.protonmail.android.uitest.util.awaitHidden +import dagger.hilt.android.testing.HiltAndroidTest +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType +import me.proton.core.compose.theme.ProtonTheme +import org.junit.Test + +@SmokeTest +@HiltAndroidTest +internal class DismissableSnackbarHostTest : HiltInstrumentedTest() { + + private val snackbarHost = composeTestRule.onNodeWithTag(MainSnackbarTestTag) + + @Test + fun snackbarCanBeDismissedRtl() { + // Given + prepareScreenWithSnackbarHost() + snackbarHost.awaitDisplayed() + + // When + snackbarHost.performTouchInput { + // Override startX and endX otherwise it does not have enough space to perform the swipe action. + swipeLeft( + startX = snackbarHost.getBoundsInRoot().right.value, + endX = snackbarHost.getBoundsInRoot().left.value + ) + } + + // Then + snackbarHost.awaitHidden() + } + + @Test + fun snackbarCanBeDismissedLtr() { + // Given + prepareScreenWithSnackbarHost() + snackbarHost.awaitDisplayed() + + // When + snackbarHost.performTouchInput { + swipeRight() + } + + // Then + snackbarHost.awaitHidden() + } + + private fun prepareScreenWithSnackbarHost() { + composeTestRule.setContent { + ProtonTheme { + val protonSnackbarHostState = remember { ProtonSnackbarHostState() } + + LaunchedEffect(Unit) { + protonSnackbarHostState.showSnackbar( + ProtonSnackbarType.NORM, + "Hello hello hello", + duration = SnackbarDuration.Indefinite + ) + } + + Scaffold( + scaffoldState = rememberScaffoldState(), + snackbarHost = { + DismissableSnackbarHost( + modifier = Modifier.testTag(MainSnackbarTestTag), + protonSnackbarHostState = protonSnackbarHostState + ) + } + ) { paddingValues -> + Text( + modifier = Modifier.padding(paddingValues), text = "Text" + ) + } + } + } + } + + private companion object { + + const val MainSnackbarTestTag = "MainSnackbar" + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt new file mode 100644 index 0000000000..ac8126efae --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ActivityScenarioHolder.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.test.core.app.ActivityScenario +import ch.protonmail.android.MainActivity + +internal object ActivityScenarioHolder { + + private val _scenario: Lazy> = lazy { + ActivityScenario.launch(MainActivity::class.java) + } + + val scenario: ActivityScenario + get() { + check(_scenario.isInitialized()) { + "ActivityScenario not initialized. Make sure the current test has explicitly launched the app." + } + + return _scenario.value + } + + fun initialize() { + // Under the hood it does nothing, just calls the backing field to trigger the Activity launch. + _scenario.value + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt new file mode 100644 index 0000000000..559290d33d --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/AutomationHolders.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import android.app.Instrumentation +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice + +internal object InstrumentationHolder { + + val instrumentation: Instrumentation by lazy { InstrumentationRegistry.getInstrumentation() } +} + +internal object UiDeviceHolder { + + val uiDevice: UiDevice by lazy { UiDevice.getInstance(InstrumentationHolder.instrumentation) } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt new file mode 100644 index 0000000000..623c7dbefd --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Await.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import me.proton.core.compose.component.PROTON_PROGRESS_TEST_TAG +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import ch.protonmail.android.test.utils.ComposeTestRuleHolder + +fun ComposeTestRule.awaitProgressIsHidden() { + onNodeWithTag(PROTON_PROGRESS_TEST_TAG) + .awaitHidden(this) +} + +fun SemanticsNodeInteraction.awaitDisplayed( + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule, + timeout: Duration = 10.seconds +): SemanticsNodeInteraction = also { + composeTestRule.waitUntil(timeout.inWholeMilliseconds) { nodeIsDisplayed(this) } +} + +fun SemanticsNodeInteraction.awaitHidden( + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule, + timeout: Duration = 5.seconds +): SemanticsNodeInteraction = also { + composeTestRule.waitUntil(timeout.inWholeMilliseconds) { nodeIsNotDisplayed(this) } +} + +fun SemanticsNodeInteraction.awaitEnabled( + composeTestRule: ComposeTestRule = ComposeTestRuleHolder.rule, + timeout: Duration = 5.seconds +): SemanticsNodeInteraction = apply { + composeTestRule.waitUntil(timeout.inWholeMilliseconds) { + runCatching { assertIsEnabled() }.isSuccess + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt new file mode 100644 index 0000000000..924a5796d9 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Checks.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed + +fun nodeIsDisplayed(interaction: SemanticsNodeInteraction): Boolean { + try { + interaction.assertIsDisplayed() + } catch (ignored: AssertionError) { + return false + } + return true +} + +fun nodeIsNotDisplayed(interaction: SemanticsNodeInteraction): Boolean { + try { + interaction.assertIsDisplayed() + } catch (ignored: AssertionError) { + return true + } + return false +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt new file mode 100644 index 0000000000..57e2ae9f25 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/HiltInstrumentedTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import ch.protonmail.android.test.utils.ComposeTestRuleHolder +import ch.protonmail.android.uitest.rule.MainInitializerRule +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.rules.RuleChain + +@HiltAndroidTest +open class HiltInstrumentedTest { + + private val hiltTestRule = HiltAndroidRule(this) + private val mainInitializerRule = MainInitializerRule() + val composeTestRule: ComposeContentTestRule = ComposeTestRuleHolder.createAndGetComposeRule() + + @get:Rule + val ruleChain: RuleChain = RuleChain + .outerRule(hiltTestRule) + .around(composeTestRule) + .around(mainInitializerRule) + + @Before + fun setup() { + hiltTestRule.inject() + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt new file mode 100644 index 0000000000..699724fea1 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Interactions.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionCollection +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel + +fun SemanticsNodeInteractionsProvider.onAllNodesWithText( + text: TextUiModel, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteractionCollection = onAllNodesWithText(getString(text), substring, ignoreCase, useUnmergedTree) + +fun SemanticsNodeInteractionsProvider.onAllNodesWithText( + @StringRes textRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteractionCollection = onAllNodesWithText(getString(textRes), substring, ignoreCase, useUnmergedTree) + +fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription( + @StringRes labelRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteraction = onNodeWithContentDescription(getString(labelRes), substring, ignoreCase, useUnmergedTree) + +fun SemanticsNodeInteractionsProvider.onAllNodesWithContentDescription( + @StringRes labelRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteractionCollection = onAllNodesWithContentDescription( + label = getString(labelRes), + substring = substring, + ignoreCase = ignoreCase, + useUnmergedTree = useUnmergedTree +) + +fun SemanticsNodeInteractionsProvider.onNodeWithText( + text: TextUiModel, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteraction = onNodeWithText(getString(text), substring, ignoreCase, useUnmergedTree) + +fun SemanticsNodeInteractionsProvider.onNodeWithText( + @StringRes textRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteraction = onNodeWithText(getString(textRes), substring, ignoreCase, useUnmergedTree) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt new file mode 100644 index 0000000000..c8f3f9600f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Locators.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionCollection +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onFirst + +/** + * Returns a child [SemanticsNodeInteraction] from another [SemanticsNodeInteraction] + * by filtering the parent's children with the given [SemanticsMatcher]. + */ +fun SemanticsNodeInteraction.child(matcher: () -> SemanticsMatcher): SemanticsNodeInteraction = + onChildren().filter(matcher.invoke()).onFirst() + +/** + * Returns a [SemanticsNodeInteractionCollection] from a [SemanticsNodeInteraction] + * by filtering the parent's children with the given [SemanticsMatcher]. + */ +fun SemanticsNodeInteraction.children(matcher: () -> SemanticsMatcher): SemanticsNodeInteractionCollection = + onChildren().filter(matcher.invoke()) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt new file mode 100644 index 0000000000..20f85ba532 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/Matchers.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasText +import androidx.compose.ui.text.TextLayoutResult + +fun hasText( + @StringRes textRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false +): SemanticsMatcher = hasText(getString(textRes), substring, ignoreCase) + +// Not as straightforward, some bits are taken from the compose-ui source code which can be found here: +// https://github.com/androidx/androidx/blob/3606267939d1cb78310a1e40e76f673920f277b8/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt#L1840-L1849 +fun hasTextColor(color: Color): SemanticsMatcher { + val semanticsAction = SemanticsActions.GetTextLayoutResult + + return SemanticsMatcher( + description = "${SemanticsProperties.Text.name} color matches '$color'" + ) { + val textLayoutElements = mutableListOf().apply { + it.config[semanticsAction].action?.invoke(this) + } as List + + if (textLayoutElements.isNotEmpty()) { + textLayoutElements[0].layoutInput.style.color == color + } else { + false + } + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt new file mode 100644 index 0000000000..5231b830aa --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/ResourcesUtils.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import android.app.Application +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.test.core.app.ApplicationProvider +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation + +fun getString(text: TextUiModel): String = when (text) { + is TextUiModel.Text -> text.value + is TextUiModel.TextRes -> getString(text.value) + is TextUiModel.TextResWithArgs -> getString(text.value, *text.formatArgs.toTypedArray()) + is TextUiModel.PluralisedText -> getTestString(text.value, text.value) +} + +fun getString(@StringRes resId: Int): String = ApplicationProvider.getApplicationContext().getString(resId) + +fun getString(@StringRes resId: Int, vararg formatArgs: Any): String = + ApplicationProvider.getApplicationContext().getString(resId, formatArgs) + +fun getTestString(@StringRes resId: Int, vararg formatArgs: Any): String = + instrumentation.context.getString(resId, *formatArgs) + +fun getTestString(@PluralsRes pluralResId: Int, value: Int): String = + instrumentation.context.resources.getQuantityString(pluralResId, value, value) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt new file mode 100644 index 0000000000..f8a3f7cf21 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StateManager.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import arrow.core.NonEmptyList +import arrow.core.nonEmptyListOf +import kotlinx.coroutines.flow.MutableStateFlow + +class StateManager(private val states: NonEmptyList) { + + private var index = 0 + val flow: MutableStateFlow = MutableStateFlow(states[index]) + + fun emit(state: State) { + flow.value = state + } + + fun emitNext() { + if (index == states.lastIndex) { + throw IllegalStateException("No more states to emit") + } + flow.value = states[++index] + } + + companion object { + + fun of(initialState: State, vararg nextStates: State): StateManager = + StateManager(nonEmptyListOf(initialState, *nextStates)) + + fun of(states: NonEmptyList): StateManager = + StateManager(states = states) + } +} + +interface ManagedStateScope { + + fun emitState(state: State) + + fun emitNextState() +} + +@Composable +fun ManagedState( + stateManager: StateManager, + content: @Composable ManagedStateScope.(state: State) -> Unit +) { + val scope = object : ManagedStateScope { + override fun emitState(state: State) { + stateManager.emit(state) + } + + override fun emitNextState() { + stateManager.emitNext() + } + } + val state by stateManager.flow.collectAsState() + scope.content(state) +} + +fun ComposeContentTestRule.setManagedStateContent( + stateManager: StateManager, + content: @Composable ManagedStateScope.(state: State) -> Unit +) { + setContent { + ManagedState(stateManager, content) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt new file mode 100644 index 0000000000..abdbc39dfa --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/StringUtils.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util + +internal object StringUtils { + + fun generateRandomString(length: Int): String { + val allowedRange = ('a'..'z') + ('A'..'Z') + ('0'..'9') + ' ' + return (1..length).map { allowedRange.random() }.joinToString("") + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt new file mode 100644 index 0000000000..b32fd7de00 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomMailboxAssertions.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.assertions + +import androidx.compose.ui.test.SemanticsNodeInteraction +import ch.protonmail.android.uitest.util.extensions.getKeyValueByName +import org.junit.Assert.assertEquals + +internal fun SemanticsNodeInteraction.assertItemIsRead(expectedValue: Boolean) = apply { + val isItemReadProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.IsItemReadKey)) { + "Expected IsItemReadKey property was not found on this node." + } + + assertEquals(expectedValue, isItemReadProperty.value) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt new file mode 100644 index 0000000000..14bb2d312a --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/CustomSemanticsPropertyKeyNames.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.assertions + +internal object CustomSemanticsPropertyKeyNames { + + const val TintColorKey = "TintColorKey" + const val IsItemReadKey = "IsItemReadKey" + const val IsValidFieldKey = "IsValidFieldKey" +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt new file mode 100644 index 0000000000..adf76c03b8 --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/SemanticsNodeInteractionChildAssertions.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.assertions + +import android.util.Log +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.printToString + +/** + * Traverses the UI hierarchy starting from the receiving [SemanticsNodeInteraction] to find a + * matching child with the given [SemanticsMatcher]. + * + * Note that only the first child of a given sub-hierarchy will be traversed. + * + * @param matcher the matcher to match the child element with. + * @param maxDepthLevel the maximum level of depth. + * + * @throws AssertionError if no child is found within the [maxDepthLevel]. + */ +fun SemanticsNodeInteraction.hasAnyChildWith(matcher: SemanticsMatcher, maxDepthLevel: Int = 5) { + val logTag = "SemanticsNodeInteraction#hasAnyChildWith" + var child = onChildAt(0) + + for (attempt in 1.rangeTo(maxDepthLevel)) { + try { + child.assert(matcher) + Log.d(logTag, "Found matching element at attempt $attempt.") + return + } catch (e: AssertionError) { + Log.d(logTag, "Attempt #$attempt - Unable to find a child with matcher '$matcher'.") + Log.d(logTag, "${e.message}") + child = child.onChildAt(0) + } + } + + throw AssertionError( + "Unable to find any direct zero-indexed child of the given node within $maxDepthLevel level(s) " + + "of depth with matcher '${matcher.description}'." + ).also { + Log.e(logTag, "Dumping first ancestor hierarchy...\n\n${printToString()}") + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt new file mode 100644 index 0000000000..9137ce926f --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TextAssertions.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.assertions + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import ch.protonmail.android.uitest.util.extensions.getKeyValueByName +import ch.protonmail.android.uitest.util.getString +import ch.protonmail.android.uitest.util.hasTextColor +import kotlin.test.assertEquals + +fun SemanticsNodeInteraction.assertTextColor(color: Long): SemanticsNodeInteraction = assertTextColor(Color(color)) + +fun SemanticsNodeInteraction.assertTextColor(color: Color): SemanticsNodeInteraction = assert(hasTextColor(color)) + +fun SemanticsNodeInteraction.assertEmptyText() = assertTextEquals("") + +fun SemanticsNodeInteraction.assertTextContains( + @StringRes valueRes: Int, + substring: Boolean = false, + ignoreCase: Boolean = false +): SemanticsNodeInteraction = assertTextContains(getString(valueRes), substring, ignoreCase) + +/** + * Performs an assertion against the content of the receiving node + * excluding the content of the `EditableText` [SemanticsPropertyKey] . + * + * This is useful when performing checks against nodes located with the merged tree strategy. + * + * @param value the expected [String] value. + */ +fun SemanticsNodeInteraction.assertNotEditableTextEquals(value: String) { + assertTextEquals(value, includeEditableText = false) +} + +/** + * Performs an assertion against the content of the `EditableText` [SemanticsPropertyKey] of the receiving node. + * + * This is useful when performing checks against nodes located with the merged tree strategy. + * + * @param value the expected [String] value. + */ +fun SemanticsNodeInteraction.assertEditableTextEquals(value: String) { + val editableText = requireNotNull(getKeyValueByName(SemanticsProperties.EditableText.name)) { + "Expected EditableText property was not found on this node." + } + + assertEquals(value, editableText.value.toString()) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt new file mode 100644 index 0000000000..0cf99a252e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/assertions/TintColorAssertions.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.assertions + +import androidx.compose.ui.test.SemanticsNodeInteraction +import ch.protonmail.android.uitest.models.folders.Tint +import ch.protonmail.android.uitest.util.extensions.getKeyValueByName +import org.junit.Assert.assertEquals +import kotlin.test.assertNull + +internal fun SemanticsNodeInteraction.assertTintColor(tint: Tint) = apply { + val tintColorProperty = requireNotNull(getKeyValueByName(CustomSemanticsPropertyKeyNames.TintColorKey)) { + "Expected TintColorKey property was not found on this node." + } + + when (tint) { + is Tint.WithColor -> assertEquals(tint.value, tintColorProperty.value) + is Tint.NoColor -> assertNull(tintColorProperty.value) + } +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt new file mode 100644 index 0000000000..0ad8a09cbf --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/IdResExtensions.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.extensions + +import androidx.annotation.IdRes +import ch.protonmail.android.uitest.util.InstrumentationHolder.instrumentation + +/** + * The resource ID value as full [String] in the format `:id/`. + * + * Example: + * ``` + * R.id.scrollContent.asStringResourceId -> "ch.protonmail.android.dev:id/scrollContent" + * ``` + */ +internal val @receiver:IdRes Int.asStringResourceId: String + get() = instrumentation.targetContext.resources.getResourceName(this) diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt new file mode 100644 index 0000000000..d870f754fe --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/LoginRobotExtensions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.extensions + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import ch.protonmail.android.R +import ch.protonmail.android.uitest.util.UiDeviceHolder.uiDevice +import me.proton.core.test.android.robots.auth.login.LoginRobot + +// This is needed as from the Login screen to the Mailbox, we switch from XML views to Compose layouts. +// If the Compose layout is not ready yet, checks performed on the Compose test rule +// might throw an IllegalStateException, making the test fail. +@Suppress("UnusedReceiverParameter") +fun LoginRobot.waitUntilSignInScreenIsGone(timeout: Long = 15_000L) { + // R.id.scrollContent is the root item in the Sign In XML screen (located in the Core library) + uiDevice.wait(Until.gone(By.res(R.id.scrollContent.asStringResourceId)), timeout) +} diff --git a/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt new file mode 100644 index 0000000000..0f6948046e --- /dev/null +++ b/app/src/uiTest/kotlin/ch/protonmail/android/uitest/util/extensions/SemanticsNodeInteractionExtensions.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.uitest.util.extensions + +import androidx.compose.ui.test.SemanticsNodeInteraction + +/** + * Returns a [Map.Entry] instance for the provided key, null if no entry exists. + */ +fun SemanticsNodeInteraction.getKeyValueByName(key: String): Map.Entry<*, *>? { + return fetchSemanticsNode().config.firstOrNull { + it.key.name == key + } +} diff --git a/app/src/uiTest/res/values/strings.xml b/app/src/uiTest/res/values/strings.xml new file mode 100644 index 0000000000..a24a49509d --- /dev/null +++ b/app/src/uiTest/res/values/strings.xml @@ -0,0 +1,109 @@ + + + + + Exit selection mode + Menu + Search + Exit search mode + + + Inbox + Drafts + Sent + Starred + Archive + Spam + Trash + All mail + (No Sender) + (No Recipient) + Official + + + This mailbox is empty + Loading mailbox failed + Draft saved + Sending message… + Offline, message queued for sending + Message sent + Error sending message + + + Unable to retrieve message + + + Automatic loading of embedded images is turned off. This can be changed in the settings. + Automatic loading of remote content is turned off. This can be changed in the settings. + Automatic loading of embedded images and remote content is turned off. These can be changed in the settings. + + + From: + To: + Cc: + Bcc: + Subject + Compose email + Email address is invalid + Removed duplicate recipient: %1$s + You need a paid Proton Mail subscription to change the sender address + The content of this draft is not available at this time. Editing will override any pre-existing content. + Error uploading attachment + + + Mark read + Mark unread + Star + Unstar + Label as… + Move to… + Trash + Delete + Archive + Spam + More + + + Done + + + Move to... + + Feature coming soon... + Conversation moved to %1$s + + + Settings + Report a problem + Subscription + Sign out + + + Retry + + + Current download must complete before starting a new one. + + + Message decryption failed. + + + Sign out + Cancel + diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000000..75bcea80da --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1,2 @@ +/build +benchmark.properties \ No newline at end of file diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000000..7ec3904615 --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,95 @@ +import java.util.Properties + +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +private val benchmarkProperties = Properties().apply { + @Suppress("SwallowedException") + try { + load(projectDir.resolve("benchmark.properties").inputStream()) + } catch (exception: java.io.FileNotFoundException) { + Properties() + } +} + +private val benchmarkUsername = benchmarkProperties["username"].toString() +private val benchmarkPassword = benchmarkProperties["password"].toString() + +android { + namespace = "ch.protonmail.android.benchmark" + compileSdk = Config.compileSdk + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + minSdk = Config.minSdk + targetSdk = Config.targetSdk + + missingDimensionStrategy("default", "alpha") + + buildConfigField("String", "DEFAULT_LOGIN", benchmarkUsername.toBuildConfigValue()) + buildConfigField("String", "DEFAULT_PASSWORD", benchmarkPassword.toBuildConfigValue()) + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + // This benchmark buildType is used for benchmarking, and should function like the + // release build (for example, with minification on). It's signed with a debug key + // for easy local/CI testing. + create("benchmark") { + isDebuggable = true + signingConfig = getByName("debug").signingConfig + matchingFallbacks += listOf("release") + } + } + + buildFeatures { + buildConfig = true + } + + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true +} + +dependencies { + implementation(libs.androidx.test.androidjunit) + implementation(libs.androidx.test.espresso.core) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.test.macrobenchmark) +} + +androidComponents { + beforeVariants(selector().all()) { + it.enable = it.buildType == "benchmark" + } +} + +fun String?.toBuildConfigValue() = if (this != null) "\"$this\"" else "null" diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8914ea6ad6 --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt new file mode 100644 index 0000000000..3bf3709862 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkConfig.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common + +object BenchmarkConfig { + + const val PackageName = "ch.protonmail.android.alpha" + const val WaitForLoginToDisappearTimeout = 15_000L + const val WaitForMailboxTimeout = 20_000L + const val WaitForMessageDetailsTimeout = 10_000L + const val DefaultIterations = 5 +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt new file mode 100644 index 0000000000..3472f62901 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/BenchmarkTraceUtils.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common + +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.TraceSectionMetric + +/** + * Core trace sections to be benchmarked. + */ +@OptIn(ExperimentalMetricApi::class) +fun coreTraceSectionsList(): List { + return listOf( + TraceSectionMetric("proton-app-init") + ) +} + +/** + * Remote Api trace sections to be benchmarked. + */ +@OptIn(ExperimentalMetricApi::class) +fun remoteApiTraceSectionsList(): List { + return listOf( + TraceSectionMetric("proton-api-get-conversations"), + TraceSectionMetric("proton-api-get-messages") + ) +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt new file mode 100644 index 0000000000..e8df684dee --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/MailboxUtils.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common + +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until + +/** + * Wait until the first row within the mailbox list is rendered. + */ +fun MacrobenchmarkScope.waitUntilFirstEmailRowShownOnMailboxList() { + device.wait(Until.hasObject(By.res(TestTags.MailboxListTag)), BenchmarkConfig.WaitForMailboxTimeout) + + val mailboxList = device.findObject(By.res(TestTags.MailboxListTag)) + + // Wait until the first row within the list is rendered + mailboxList.wait(Until.hasObject(By.res(TestTags.FirstMailboxItemRow)), BenchmarkConfig.WaitForMailboxTimeout) +} + + +/** + * Click on the first row and wait until message details are shown. + */ +fun MacrobenchmarkScope.clickOnTheFirstEmailRowWaitDetailsShown() { + + val mailboxList = device.findObject(By.res(TestTags.MailboxListTag)) + + val firstEmailRow = mailboxList.findObject(By.res(TestTags.FirstMailboxItemRow)) + + firstEmailRow.click() + + device.wait(Until.hasObject(By.res(TestTags.MessageBodyNoWebView)), BenchmarkConfig.WaitForMessageDetailsTimeout) +} + +/** + * Wait until the mailbox list is rendered but emails are not loaded. + */ +fun MacrobenchmarkScope.waitUntilMailboxShownButEmailsNotLoaded() { + device.wait(Until.hasObject(By.res(TestTags.MailboxRootTag)), BenchmarkConfig.WaitForMailboxTimeout) +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt new file mode 100644 index 0000000000..50c8e2d7a4 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/StartupUtils.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common + +import android.widget.EditText +import androidx.benchmark.macro.MacrobenchmarkScope +import ch.protonmail.android.benchmark.BuildConfig +import ch.protonmail.android.benchmark.common.extensions.findUiObjectByClassWithParent +import ch.protonmail.android.benchmark.common.extensions.findUiObjectByResource +import ch.protonmail.android.benchmark.common.extensions.findUiObjectByText +import ch.protonmail.android.benchmark.common.extensions.waitUntilGone +import ch.protonmail.android.benchmark.common.identifiers.ResourceIdentifiers +import ch.protonmail.android.benchmark.common.identifiers.TextIdentifiers + +internal fun MacrobenchmarkScope.performLogin( + username: String = BuildConfig.DEFAULT_LOGIN, + password: String = BuildConfig.DEFAULT_PASSWORD +) { + // To be refactored with the Robot pattern. + with(device) { + findUiObjectByResource(ResourceIdentifiers.SignInButton).click() + + findUiObjectByClassWithParent(EditText::class.java, ResourceIdentifiers.UsernameInput) + .setText(username) + + findUiObjectByClassWithParent(EditText::class.java, ResourceIdentifiers.PasswordInput) + .setText(password) + + findUiObjectByResource(ResourceIdentifiers.PerformSignInButton).click() + + // Permission handling needs to be done here for now since the Login root view is still displayed underneath. + runCatching { + // TBC if this breaks as it depends on platform specific ids, which might change depending on the device. + device.findUiObjectByResource(ResourceIdentifiers.AllowPermission).click() + } + + waitUntilGone(ResourceIdentifiers.LoginScreenRootView, BenchmarkConfig.WaitForLoginToDisappearTimeout) + } +} + +internal fun MacrobenchmarkScope.skipOnboarding() { + val expectedOnboardingPages = 3 + + with(device) { + repeat(expectedOnboardingPages) { + findUiObjectByText(TextIdentifiers.OnboardingScreenButtonText).click() + } + + findUiObjectByText(TextIdentifiers.OnboardingCompleteButtonText).click() + } +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt new file mode 100644 index 0000000000..6ef805f17a --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/TestTags.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common + +/** + * Test Tags (which are used as resource ids) + */ +object TestTags { + const val MailboxRootTag = "MailboxScreen" + const val FirstMailboxItemRow = "MailboxItemRow0" + const val MailboxListTag = "MailboxList" + const val MessageBodyNoWebView = "MessageBodyNoWebView" +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt new file mode 100644 index 0000000000..ff72bd3cdc --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/extensions/UiDeviceExtensions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common.extensions + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until + +internal fun UiDevice.findUiObjectByText(text: String) = findObject(UiSelector().text(text)) + +internal fun UiDevice.findUiObjectByResource(resId: String) = findObject(UiSelector().resourceId(resId)) + +internal fun UiDevice.findUiObjectByClassWithParent(childClass: Class<*>, parentResourceId: String) = findObject( + UiSelector().resourceId(parentResourceId).childSelector(UiSelector().className(childClass)) +) + +internal fun UiDevice.waitUntilGone(resId: String, timeout: Long) = wait(Until.gone(By.res(resId)), timeout) diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt new file mode 100644 index 0000000000..976fc4247e --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/ResourceIdentifiers.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common.identifiers + +import ch.protonmail.android.benchmark.common.BenchmarkConfig + +internal object ResourceIdentifiers { + + private const val PackageName = BenchmarkConfig.PackageName + + const val SignInButton = "$PackageName:id/sign_in" + const val UsernameInput = "$PackageName:id/usernameInput" + const val PasswordInput = "$PackageName:id/passwordInput" + const val PerformSignInButton = "$PackageName:id/signInButton" + const val AllowPermission = "com.android.permissioncontroller:id/permission_allow_button" + const val LoginScreenRootView = "$PackageName:id/scrollContent" +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt new file mode 100644 index 0000000000..201632f697 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/common/identifiers/TextIdentifiers.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.common.identifiers + +internal object TextIdentifiers { + + const val OnboardingScreenButtonText = "Next" + const val OnboardingCompleteButtonText = "Get started" +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt new file mode 100644 index 0000000000..27f674393a --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/convdetail/ConversationDetailsBenchmark.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.convdetail + +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import ch.protonmail.android.benchmark.common.BenchmarkConfig +import ch.protonmail.android.benchmark.common.clickOnTheFirstEmailRowWaitDetailsShown +import ch.protonmail.android.benchmark.common.coreTraceSectionsList +import ch.protonmail.android.benchmark.common.performLogin +import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList +import ch.protonmail.android.benchmark.common.skipOnboarding +import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class ConversationDetailsBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * Start the application + * Wait for the mailbox to be visible and messages loaded + * Click on the first conversation + * Wait for conversation details to be visible + */ + @OptIn(ExperimentalMetricApi::class) + @Test + fun testLoadingConversationDetailsScreen() { + var firstStart = true + + benchmarkRule.measureRepeated( + packageName = BenchmarkConfig.PackageName, + metrics = listOf( + StartupTimingMetric(), + FrameTimingMetric() + ) + coreTraceSectionsList() + + remoteApiTraceSectionsList(), + iterations = BenchmarkConfig.DefaultIterations, + startupMode = StartupMode.COLD, + setupBlock = { + if (!firstStart) return@measureRepeated + + startActivityAndWait() + performLogin() + skipOnboarding() + firstStart = false + + pressHome() + } + ) { + + startActivityAndWait() + + waitUntilFirstEmailRowShownOnMailboxList() + + clickOnTheFirstEmailRowWaitDetailsShown() + } + } +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt new file mode 100644 index 0000000000..5e648f3244 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/scroll/ScrollMailboxBenchmark.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.scroll + +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import ch.protonmail.android.benchmark.common.BenchmarkConfig +import ch.protonmail.android.benchmark.common.coreTraceSectionsList +import ch.protonmail.android.benchmark.common.performLogin +import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList +import ch.protonmail.android.benchmark.common.skipOnboarding +import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList +import junit.framework.TestCase +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * This is a benchmark that scrolls the mailbox list up and down and measures how long the frames took. + * + * It navigates to the device's home screen, and launches the default activity. Note that it does not go through + * the login, it expects a user to be already logged in before the benchmark is ran. + * + * In order to run, select the alphaBenchmark build variant for the app module. + */ +@RunWith(AndroidJUnit4::class) +class ScrollMailboxBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun scrollMailbox() = scroll() + + @OptIn(ExperimentalMetricApi::class) + private fun scroll() { + var firstStart = true + benchmarkRule.measureRepeated( + packageName = BenchmarkConfig.PackageName, + metrics = listOf( + StartupTimingMetric(), + FrameTimingMetric() + ) + coreTraceSectionsList() + + remoteApiTraceSectionsList(), + startupMode = null, + iterations = BenchmarkConfig.DefaultIterations, + setupBlock = { + if (!firstStart) return@measureRepeated + + startActivityAndWait() + performLogin() + skipOnboarding() + firstStart = false + } + ) { + waitUntilFirstEmailRowShownOnMailboxList() + + val scrollableObject = device.findObject(By.scrollable(true)) + if (scrollableObject == null) { + TestCase.fail("No scrollable view found in hierarchy") + } + scrollableObject.setGestureMargin(device.displayWidth / GestureMarginRatio) + scrollableObject?.apply { + repeat(NumberOfListFlings) { + fling(Direction.DOWN) + } + repeat(NumberOfListFlings) { + fling(Direction.UP) + } + } + } + } + + companion object { + + const val GestureMarginRatio = 10 + const val NumberOfListFlings = 5 + } +} diff --git a/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt b/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt new file mode 100644 index 0000000000..8abd7e1e30 --- /dev/null +++ b/benchmark/src/main/java/ch/protonmail/android/benchmark/startup/StartupBenchmark.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.benchmark.startup + +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import ch.protonmail.android.benchmark.common.BenchmarkConfig +import ch.protonmail.android.benchmark.common.coreTraceSectionsList +import ch.protonmail.android.benchmark.common.performLogin +import ch.protonmail.android.benchmark.common.remoteApiTraceSectionsList +import ch.protonmail.android.benchmark.common.skipOnboarding +import ch.protonmail.android.benchmark.common.waitUntilFirstEmailRowShownOnMailboxList +import ch.protonmail.android.benchmark.common.waitUntilMailboxShownButEmailsNotLoaded +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + /** + * Measure the cold startup time when the mailbox is visible but emails are not loaded. + */ + @OptIn(ExperimentalMetricApi::class) + @Test + fun coldStartMailboxVisibleNoEmailsLoaded() = benchmarkRule.measureRepeated( + packageName = BenchmarkConfig.PackageName, + metrics = listOf( + StartupTimingMetric(), + FrameTimingMetric() + ) + coreTraceSectionsList(), + iterations = BenchmarkConfig.DefaultIterations, + startupMode = StartupMode.COLD, + setupBlock = { + pressHome() + } + ) { + + startActivityAndWait() + + waitUntilMailboxShownButEmailsNotLoaded() + } + + /** + * Measure the cold startup time when the mailbox is visible and emails are loaded. + * We do not wait for all emails to be loaded from network, we only wait for the first + * one to be shown in the mailbox. + * + * At that point,we mark MainActivity to be fully drawn. + */ + @OptIn(ExperimentalMetricApi::class) + @Test + fun coldStartMailboxVisibleWithEmailsLoaded() { + var firstStart = true + benchmarkRule.measureRepeated( + packageName = BenchmarkConfig.PackageName, + metrics = listOf( + StartupTimingMetric(), + FrameTimingMetric() + ) + coreTraceSectionsList() + + remoteApiTraceSectionsList(), + iterations = BenchmarkConfig.DefaultIterations, + startupMode = StartupMode.COLD, + setupBlock = { + if (!firstStart) return@measureRepeated + + startActivityAndWait() + performLogin() + skipOnboarding() + firstStart = false + + pressHome() + } + ) { + + startActivityAndWait() + + waitUntilFirstEmailRowShownOnMailboxList() + } + } +} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index d4efb6264f..0000000000 --- a/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath "com.android.tools.build:gradle:7.0.3" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31" - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000..f14b26b0b0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +buildscript { + repositories { + google() + } + dependencies { + classpath(libs.android.tools.build) + classpath(libs.kotlin.gradle) + classpath(libs.hilt.android.gradle) + classpath(libs.google.services) + classpath(libs.sentry.gradle) + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + alias(libs.plugins.google.devtools.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.proton.core.detekt) + alias(libs.plugins.proton.core.coverage.config) + alias(libs.plugins.proton.core.coverage) apply false + alias(libs.plugins.proton.core.global.coverage) apply false + alias(libs.plugins.compose.compiler) apply false +} + +subprojects { + if (project.findProperty("enableComposeCompilerReports") == "true") { + kotlinCompilerArgs( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + + project.layout.buildDirectory.asFile.get().absolutePath + "/compose_reports", + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + + project.layout.buildDirectory.asFile.get().absolutePath + "/compose_metrics" + ) + } + + afterEvaluate { + dependencies { + configurations.findByName("detektPlugins")?.let { + add("detektPlugins", project(":detekt-rules")) + } + } + tasks.findByName("detekt")?.dependsOn(":detekt-rules:assemble") + } +} + +protonDetekt { + threshold = 0 +} + +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) +} + +setupTests() + +kotlinCompilerArgs( + "-opt-in=kotlin.RequiresOptIn", + // Enables experimental Coroutines API. + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + // Enables experimental Time (Turbine). + "-opt-in=kotlin.time.ExperimentalTime" +) + +fun Project.kotlinCompilerArgs(vararg extraCompilerArgs: String) { + for (sub in subprojects) { + sub.tasks.withType { + kotlinOptions { freeCompilerArgs = freeCompilerArgs + extraCompilerArgs } + } + } +} + + +fun Project.setupTests() { + fun Project.isRootProject() = this@isRootProject.subprojects.size != 0 + + for (sub in subprojects) { + + // Apply coverage plugin to non subprojects. + if (!sub.isRootProject()) { + sub.afterEvaluate { pluginManager.apply("me.proton.core.gradle-plugins.coverage") } + } + + sub.tasks.withType { + // Test logging + testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + + // Additional JVM args to bypass strong encapsulation (needed for mocking) + jvmArgs( + "--add-opens", "java.base/java.util=ALL-UNNAMED" + ) + } + } +} + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..1ea6fd03e8 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + `kotlin-dsl` +} + +repositories { + maven("https://plugins.gradle.org/m2/") +} diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt new file mode 100644 index 0000000000..41633ce22f --- /dev/null +++ b/buildSrc/src/main/kotlin/Config.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +object Config { + const val applicationId = "ch.protonmail.android" + const val compileSdk = 35 + const val minSdk = 28 + const val targetSdk = 34 + const val testInstrumentationRunner = "ch.protonmail.android.uitest.HiltTestRunner" + const val versionCode = 1 + const val versionName = "4.10.1" +} diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt new file mode 100644 index 0000000000..a93a718504 --- /dev/null +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies +import kotlin.apply +import kotlin.io.inputStream + +fun String.runCommand( + workingDir: File = File("."), + timeoutAmount: Long = 60, + timeoutUnit: TimeUnit = TimeUnit.SECONDS +): String = ProcessBuilder(split("\\s(?=(?:[^'\"`]*(['\"`])[^'\"`]*\\1)*[^'\"`]*$)".toRegex())) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + .apply { waitFor(timeoutAmount, timeoutUnit) } + .run { + val error = errorStream.bufferedReader().readText().trim() + if (error.isNotEmpty()) { + throw IOException(error) + } + inputStream.bufferedReader().readText().trim() + } diff --git a/ci/cache-policy-pull.yml b/ci/cache-policy-pull.yml new file mode 100644 index 0000000000..e63de71b70 --- /dev/null +++ b/ci/cache-policy-pull.yml @@ -0,0 +1,3 @@ +.cache-policy: + cache: + policy: pull diff --git a/ci/cache-policy-push-pull.yml b/ci/cache-policy-push-pull.yml new file mode 100644 index 0000000000..4635ceace0 --- /dev/null +++ b/ci/cache-policy-push-pull.yml @@ -0,0 +1,3 @@ +.cache-policy: + cache: + policy: pull-push diff --git a/ci/templates/base-jobs.gitlab-ci.yml b/ci/templates/base-jobs.gitlab-ci.yml new file mode 100644 index 0000000000..aac251b728 --- /dev/null +++ b/ci/templates/base-jobs.gitlab-ci.yml @@ -0,0 +1,36 @@ +.build_job: + stage: build + tags: + - android-xlarge + artifacts: + paths: + - app/build/outputs + +.firebase_test_job: + stage: test + dependencies: + - build_dev_debug + tags: + - shared-small + before_script: + - !reference [before_script] + - ./scripts/setup_firebase_gcloud.sh + cache: + policy: pull + +.firebase_deploy_job: + stage: deploy + interruptible: false + dependencies: + - build_alpha_release + tags: + - shared-small + before_script: + - !reference [before_script] + - echo $SERVICE_ACCOUNT_MAIL > /tmp/service-account.json + - ./scripts/release/generate_git_release_notes.sh /tmp/release_notes.txt + rules: + - if: $CI_PIPELINE_SOURCE == "parent_pipeline" + when: never + cache: + policy: pull diff --git a/config/google-services/dummy-google-services.json b/config/google-services/dummy-google-services.json new file mode 100644 index 0000000000..ef6cc4b921 --- /dev/null +++ b/config/google-services/dummy-google-services.json @@ -0,0 +1,167 @@ +{ + "project_info": { + "project_number": "111111111111", + "firebase_url": "", + "project_id": "", + "storage_bucket": "" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:111111111111:android:1111111111111111", + "android_client_info": { + "package_name": "ch.protonmail.android" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "DummyApiKey" + }, + { + "current_key": "DummyApiKey" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 2, + "ios_info": { + "bundle_id": "ch.protonmail.protonmail" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "2:111111111111:android:1111111111111111", + "android_client_info": { + "package_name": "ch.protonmail.android.alpha" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "DummyApiKey" + }, + { + "current_key": "DummyApiKey" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 2, + "ios_info": { + "bundle_id": "ch.protonmail.protonmail" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "3:111111111111:android:1111111111111111", + "android_client_info": { + "package_name": "ch.protonmail.android.beta" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "DummyApiKey" + }, + { + "current_key": "DummyApiKey" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 2, + "ios_info": { + "bundle_id": "ch.protonmail.protonmail" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "4:111111111111:android:1111111111111111", + "android_client_info": { + "package_name": "ch.protonmail.android.dev" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "DummyApiKey" + }, + { + "current_key": "DummyApiKey" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 2, + "ios_info": { + "bundle_id": "ch.protonmail.protonmail" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/coverage/build.gradle.kts b/coverage/build.gradle.kts new file mode 100644 index 0000000000..63c0f941b2 --- /dev/null +++ b/coverage/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + `kotlin-dsl` + id("me.proton.core.gradle-plugins.global-coverage") +} diff --git a/detekt-rules/build.gradle.kts b/detekt-rules/build.gradle.kts new file mode 100644 index 0000000000..8bcc0b4999 --- /dev/null +++ b/detekt-rules/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + kotlin("jvm") +} + +dependencies { + compileOnly(libs.detekt.api) + + testImplementation(libs.detekt.test) + testImplementation(libs.kotlin.test) +} diff --git a/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt new file mode 100644 index 0000000000..5c0bb8985c --- /dev/null +++ b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/MailRuleSetProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package me.proton.mail.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class MailRuleSetProvider : RuleSetProvider { + + override val ruleSetId: String = "MailRules" + + override fun instance(config: Config): RuleSet { + return RuleSet( + ruleSetId, + listOf( + UseComposableActions() + ) + ) + } +} diff --git a/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt new file mode 100644 index 0000000000..4e6670da7a --- /dev/null +++ b/detekt-rules/src/main/kotlin/me/proton/mail/detekt/UseComposableActions.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package me.proton.mail.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import me.proton.mail.detekt.UseComposableActions.Companion.Threshold +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtParameter + +/** + * Reports functions annotated as `@Composable` with [Threshold] or more lambda parameters + * ```kotlin + * // compliant code + * @Composable + * fun SomeComposable( + * onBack: () -> Unit + * ) + * + * // compliant code + * fun NotComposable( + * first: () -> Unit, + * second: () -> Unit, + * third: () -> Unit, + * fourth: () -> Unit, + * ) + * + * // non-compliant code + * @Composable + * fun SomeComposable( + * onBack: () -> Unit, + * second: () -> Unit, + * third: () -> Unit, + * fourth: () -> Unit, + * ) + * ``` + */ +class UseComposableActions : Rule() { + + override val issue = Issue( + javaClass.simpleName, + Severity.Maintainability, + Description, + Debt.FIVE_MINS + ) + + override fun visitNamedFunction(function: KtNamedFunction) { + super.visitNamedFunction(function) + + val annotationNames = function.annotationEntries.map { annotation -> annotation.shortName.toString() } + if ("Composable" in annotationNames) { + + val lambdaParametersCount = function.valueParameters.count(::isNotComposableLambda) + if (lambdaParametersCount >= Threshold) { + report(CodeSmell(issue, Entity.atName(function), Message)) + } + } + } + + private fun isLambda(parameter: KtParameter) = ") -> " in parameter.text + private fun isNotComposableLambda(parameter: KtParameter) = isLambda(parameter) && "@Composable" !in parameter.text + private companion object { + + const val Description = "This rule reports a Composable functions with too many lambda parameters." + const val Message = "Too many lambda parameters: wrap them into an Actions class instead." + const val Threshold = 3 + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 0000000000..11687c2f66 --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +me.proton.mail.detekt.MailRuleSetProvider diff --git a/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt b/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt new file mode 100644 index 0000000000..bbe7b18f07 --- /dev/null +++ b/detekt-rules/src/test/kotlin/me/proton/mail/detekt/UseComposableActionsTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package me.proton.mail.detekt + +import io.gitlab.arturbosch.detekt.test.lint +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class UseComposableActionsTest { + + private val rule = UseComposableActions() + + @Test + fun `reports Composable with lambda parameters above or same as the threshold`() { + // given + val expected = 1 + val code = """ + @Composable + fun SomeScreen( + first: () -> Unit, + second: () -> Unit, + third: () -> Unit, + fourth: () -> Unit + ) + """.trimIndent() + + // when + val findings = rule.lint(code) + + // then + assertEquals(expected, findings.size) + } + + @Test + fun `does not report Composable with lambda parameters below the threshold`() { + // given + val expected = 0 + val code = """ + @Composable + fun SomeScreen( + onBack: () -> Unit, + onNext: () -> Unit + ) + """.trimIndent() + + // when + val findings = rule.lint(code) + + // then + assertEquals(expected, findings.size) + } + + @Test + fun `does not report not Composable with lambda parameters above or same as the threshold`() { + // given + val expected = 0 + val code = """ + fun NotComposable( + first: () -> Unit, + second: () -> Unit, + third: () -> Unit, + fourth: () -> Unit + ) + """.trimIndent() + + // when + val findings = rule.lint(code) + + // then + assertEquals(expected, findings.size) + } + + @Test + fun `ignores lambda annotated as Composable`() { + // given + val expected = 0 + val code = """ + @Composable + fun SomeScreen( + first: () -> Unit, + second: @Composable () -> Unit, + third: @Composable () -> Unit, + fourth: @Composable () -> Unit + ) + """.trimIndent() + + // when + val findings = rule.lint(code) + + // then + assertEquals(expected, findings.size) + } +} diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000000..2e4bde4fb2 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,2 @@ +json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one +package_name("com.example.myfirstapp") # e.g. com.krausefx.app diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000000..b0a85f5e9d --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,132 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# +opt_out_usage + +default_platform(:android) + +platform :android do + + desc "Execute static analysis" + lane :analyse do + gradle(task: "detekt") + end + + desc "Assemble the devDebug APK" + lane :assembleDevDebug do + gradle(task: "assembleDevDebug", properties: { "enableFcmService" => false }) + end + + desc "Assemble the androidTest APK" + lane :assembleDevDebugAndroidTest do + gradle(task: "assembleDevDebugAndroidTest") + end + + desc "Assemble the release APK for alpha flavor" + lane :assembleAlphaRelease do + bumpAppVersion + gradle(task: "assembleAlphaRelease") + end + + desc "Assemble the release APK for prod flavor" + lane :assembleProdRelease do + bumpAppVersion + gradle(task: "assembleProdRelease") + end + + desc "Bump the version code" + lane :bumpAppVersion do + sh("../scripts/release/bump_version.sh") + end + + desc "Runs Unit Tests" + lane :unitTest do + gradle(tasks: ["testDevDebugUnitTest", "testDebugUnitTest", "testUtilsUnitTest"]) + end + + desc "Setup UI Tests assets" + lane :setupUiTestsAssets do + sh("../scripts/uitests/setup-core-assets.sh") + sh("../scripts/uitests/setup-mock-network-assets.sh setup-remote") + end + + desc "Runs Proton Core Libraries Tests Suite on Firebase Test lab" + lane :coreLibsTest do + sh("../scripts/run_firebase_ui_tests.sh core-libs-test") + end + + desc "Runs Smoke Tests Suite on Firebase Test lab" + lane :smokeTest do + sh("../scripts/run_firebase_ui_tests.sh smoke-test") + end + + desc "Runs all UI tests on a wide set of devices on Firebase Test lab" + lane :fullRegressionTest do + sh("../scripts/run_firebase_ui_tests.sh full-regression-test") + end + + desc "Generate test coverage report based on the last test run" + lane :coverageReport do + gradle(task: "-Pci --console=plain coberturaXmlReport globalLineCoverage :coverage:koverHtmlReport -x :coverage:jacocoToCobertura") + end + + desc "Publish alpha build to firebase dev group" + lane :deployToFirebaseDevGroup do + firebase_app_distribution( + app: '1:75309174866:android:d354e9e5da9113aa78cf8b', + android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk', + # Service account is created on the CI from an env var each run (destroyed when finishing) + service_credentials_file: '/tmp/service-account.json', + groups: 'v6-dev-builds-testers', + release_notes_file: '/tmp/release_notes.txt' + ) + end + + desc "Publish nightly alpha build to firebase nightly group" + lane :deployToFirebaseNightlyGroup do + firebase_app_distribution( + app: '1:75309174866:android:d354e9e5da9113aa78cf8b', + android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk', + # Service account is created on the CI from an env var each run (destroyed when finishing) + service_credentials_file: '/tmp/service-account.json', + groups: 'v6-nightly-builds-testers', + release_notes_file: '/tmp/release_notes.txt' + ) + end + + desc "Publish alpha build to firebase alpha group" + lane :deployToFirebaseInternalAlphaGroup do + firebase_app_distribution( + app: '1:75309174866:android:d354e9e5da9113aa78cf8b', + android_artifact_path: 'app/build/outputs/apk/alpha/release/app-alpha-release.apk', + # Service account is created on the CI from an env var each run (destroyed when finishing) + service_credentials_file: '/tmp/service-account.json', + groups: 'v6-internal-alpha-testers', + release_notes_file: '/tmp/release_notes.txt' + ) + end + + desc "Tag commit with release version name and code" + lane :tagRelease do + sh("../scripts/release/tag_release.sh") + end + + desc "Deploy to Play Store (Internal Track)" + lane :deployToPlayStoreInternal do + upload_to_play_store( + package_name: "ch.protonmail.android", + track: "internal", + apk: "./app/build/outputs/apk/prod/release/app-prod-release.apk", + json_key: "/tmp/play_store_service_account.json" + ) + end +end + diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000000..b18539bc9b --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-firebase_app_distribution' diff --git a/firebase-device-config.yml b/firebase-device-config.yml new file mode 100644 index 0000000000..350bc92378 --- /dev/null +++ b/firebase-device-config.yml @@ -0,0 +1,18 @@ +fullTest: + device: + - model: MediumPhone.arm + version: 34 + - model: Pixel2.arm + version: 33 + - model: Pixel2.arm + version: 29 + - model: Pixel2.arm + version: 28 +smokeTest: + device: + - model: MediumPhone.arm + version: 34 + - model: Pixel2.arm + version: 30 + - model: Pixel2.arm + version: 28 diff --git a/gradle.properties b/gradle.properties index 98bed167dc..a9d15a1bdd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,21 +1,43 @@ +# +# Copyright (c) 2022 Proton Technologies AG +# This file is part of Proton Technologies AG and Proton Mail. +# +# Proton Mail is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Proton Mail is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Proton Mail. If not, see . +# + # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html +org.gradle.caching=true # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +android.experimental.enableArtProfiles=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false +# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#r8-full-mode +android.enableR8.fullMode=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +# IncludeGit Gradle Plugin: override include with local. +#auto.include.git.dirs=../ +#local.git.proton-libs=../proton-libs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..b6e285edcb --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,413 @@ +[versions] +android-gradle-plugin = "8.7.3" +google-services-plugin = "4.4.2" +proton-core-plugin = "1.3.0" +sentry-gradle-plugin = "4.14.1" + +accompanist = "0.30.1" +desugar-jdk-libs = "2.1.3" +androidx-activity = "1.9.3" +androidx-annotation = "1.9.1" +androidx-appcompat = "1.7.0" +androidx-biometrics = "1.2.0-alpha05" +androidx-compose = "1.7.5" +androidx-core = "1.13.1" # 1.15.0 requires compileSdk = 35 +androidx-compose-tracing = "1.7.5" +androidx-constraintlayout-compose = "1.0.1" # See MAILANDR-2396 +androidx-customview = "1.2.0-alpha02" +androidx-customview-poolingcontainer = "1.0.0" +androidx-datastore = "1.1.1" +androidx-hilt = "1.2.0" +androidx-lifecycle = "2.8.7" +androidx-material3 = "1.3.1" +androidx-navigation = "2.8.4" +androidx-paging = "3.3.4" +androidx-paging-compose = "3.3.4" +androidx-perfetto = "1.0.0" +androidx-profile-installer = "1.4.1" +androidx-room = "2.7.0-alpha10" +androidx-splashscreen = "1.0.1" +androidx-test-androidjunit = "1.2.1" +androidx-test-core = "1.6.1" +androidx-test-macrobenchmark = "1.3.3" +androidx-test-monitor = "1.7.2" +androidx-test-runner = "1.6.2" +androidx-test-rules = "1.6.1" +androidx-test-orchestrator = "1.5.1" +androidx-test-espresso = "3.6.1" +androidx-test-uiautomator = "2.3.0" +androidx-tracing = "1.2.0" +androidx-webkit = "1.12.1" +androidx-work = "2.9.1" # 2.10.0 requires compileSdk = 35 +arrow-core = "1.2.4" +cash-turbine = "1.0.0" +coil = "2.4.0" +dagger = "2.49" +detekt = "1.23.5" +firebase-bom = "33.6.0" +guava = "33.2.1-jre" +timber = "5.0.1" +javax-inject = "1" +junit = "4.13.2" +jsoup = "1.16.1" +ksp-symbol-processing-api = "2.0.21-1.0.27" +kotlin = "2.0.21" +kotlin-compile-testing = "0.6.0" +kotlinx-coroutines = "1.8.0" +kotlinx-immutable-collections = "0.3.5" +kotlinx-serialization = "1.6.3" +material = "1.12.0" +mockk = "1.13.13" +play-review = "2.0.2" +proton-core = "32.0.0" +kotlinpoet-ksp = "2.0.0" +leakcanary = "2.12" +okhttp = "4.12.0" +retrofit = "2.11.0" +sentry = "7.22.5" + +[plugins] +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp-symbol-processing-api" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +proton-core-detekt = { id = "me.proton.core.gradle-plugins.detekt", version.ref = "proton-core-plugin" } +proton-core-coverage = { id = "me.proton.core.gradle-plugins.coverage", version.ref = "proton-core-plugin" } +proton-core-coverage-config = { id = "me.proton.core.gradle-plugins.coverage-config", version.ref = "proton-core-plugin" } +proton-core-global-coverage = { id = "me.proton.core.gradle-plugins.global-coverage", version.ref = "proton-core-plugin" } + +[libraries] +# Classpath dependencies +android-tools-build = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } +kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +hilt-android-gradle = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "dagger" } +google-services = { module = "com.google.gms:google-services", version.ref = "google-services-plugin" } +sentry-gradle = { module = "io.sentry:sentry-android-gradle-plugin", version.ref = "sentry-gradle-plugin" } + +# Standard dependencies +accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-tools-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" } +android-material = { module = "com.google.android.material:material", version.ref = "material" } +androidx-activity-ktx = { module = "androidx.activity:activity", version.ref = "androidx-activity" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout-compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidx-compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "androidx-compose" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } +androidx-customview = { module = "androidx.customview:customview", version.ref = "androidx-customview" } +androidx-customview-poolingcontainer = { module = "androidx.customview:customview-poolingcontainer", version.ref = "androidx-customview-poolingcontainer" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-biometrics = { module = "androidx.biometric:biometric-ktx", version.ref = "androidx-biometrics" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" } +androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" } +androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "androidx-paging-compose" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging-compose" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidx-profile-installer" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } +androidx-test-androidjunit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-androidjunit" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } +androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-espresso-web = { module = "androidx.test.espresso:espresso-web", version.ref = "androidx-test-espresso" } +androidx-test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "androidx-test-espresso" } +androidx-test-macrobenchmark = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-test-macrobenchmark" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" } +androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-test-monitor" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-test-uiautomator" } +androidx-tracing = { module = "androidx.tracing:tracing", version.ref = "androidx-tracing" } +androidx-tracing-compose-runtime = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-tracing" } +androidx-tracing-perfetto = { module = "androidx.tracing:tracing-perfetto", version.ref = "androidx-perfetto" } +androidx-tracing-perfetto-binary = { module = "androidx.tracing:tracing-perfetto-binary", version.ref = "androidx-perfetto" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } +androidx-work-runtimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } +arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow-core" } +cash-turbine = { module = "app.cash.turbine:turbine", version.ref = "cash-turbine" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } +dagger-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" } +dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "dagger" } +dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } +detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } +detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javax-inject" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +google-play-review = { module = "com.google.android.play:review", version.ref = "play-review" } +google-play-reviewKtx = { module = "com.google.android.play:review-ktx", version.ref = "play-review" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinCompileTesting = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kotlin-compile-testing" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +ksp-symbolProcessingApi = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp-symbol-processing-api" } +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet-ksp" } +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +kotlinx-immutableCollections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable-collections" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +sentry-compose = { module = "io.sentry:sentry-compose", version.ref = "sentry" } +sentry = { module = "io.sentry:sentry", version.ref = "sentry" } +plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" } + +# Proton Core libraries +proton-core-account = { module = "me.proton.core:account", version.ref = "proton-core" } +proton-core-account-data = { module = "me.proton.core:account-data", version.ref = "proton-core" } +proton-core-accountManager = { module = "me.proton.core:account-manager", version.ref = "proton-core" } +proton-core-accountManagerPresentationCompose = { module = "me.proton.core:account-manager-presentation-compose", version.ref = "proton-core" } +proton-core-accountRecovery = { module = "me.proton.core:account-recovery", version.ref = "proton-core" } +proton-core-accountRecoveryTest = { module = "me.proton.core:account-recovery-test", version.ref = "proton-core" } +proton-core-auth = { module = "me.proton.core:auth", version.ref = "proton-core" } +proton-core-authTest = { module = "me.proton.core:auth-test", version.ref = "proton-core" } +proton-core-authFido = { module = "me.proton.core:auth-fido", version.ref = "proton-core" } +proton-core-biometric = { module = "me.proton.core:biometric", version.ref = "proton-core" } +proton-core-challenge = { module = "me.proton.core:challenge", version.ref = "proton-core" } +proton-core-contact = { module = "me.proton.core:contact", version.ref = "proton-core" } +proton-core-contact-domain = { module = "me.proton.core:contact-domain", version.ref = "proton-core" } +proton-core-configuration-data = { module = "me.proton.core:configuration-data", version.ref = "proton-core" } +proton-core-configuration-dagger-static = { module = "me.proton.core:configuration-dagger-staticdefaults", version.ref = "proton-core" } +proton-core-configuration-dagger-contentProvider = { module = "me.proton.core:configuration-dagger-content-resolver", version.ref = "proton-core" } +proton-core-country = { module = "me.proton.core:country", version.ref = "proton-core" } +proton-core-crypto = { module = "me.proton.core:crypto", version.ref = "proton-core" } +proton-core-cryptoValidator = { module = "me.proton.core:crypto-validator", version.ref = "proton-core" } +proton-core-data = { module = "me.proton.core:data", version.ref = "proton-core" } +proton-core-dataRoom = { module = "me.proton.core:data-room", version.ref = "proton-core" } +proton-core-deviceMigration = { module = "me.proton.core:device-migration", version.ref = "proton-core" } +proton-core-domain = { module = "me.proton.core:domain", version.ref = "proton-core" } +proton-core-eventManager = { module = "me.proton.core:event-manager", version.ref = "proton-core" } +proton-core-featureFlag = { module = "me.proton.core:feature-flag", version.ref = "proton-core" } +proton-core-humanVerification = { module = "me.proton.core:human-verification", version.ref = "proton-core" } +proton-core-key = { module = "me.proton.core:key", version.ref = "proton-core" } +proton-core-keyTransparency = { module = "me.proton.core:key-transparency", version.ref = "proton-core" } +proton-core-label = { module = "me.proton.core:label", version.ref = "proton-core" } +proton-core-label-data = { module = "me.proton.core:label-data", version.ref = "proton-core" } +proton-core-label-domain = { module = "me.proton.core:label-domain", version.ref = "proton-core" } +proton-core-mailSendPreferences = { module = "me.proton.core:mail-send-preferences", version.ref = "proton-core" } +proton-core-mailSettings = { module = "me.proton.core:mail-settings", version.ref = "proton-core" } +proton-core-network = { module = "me.proton.core:network", version.ref = "proton-core" } +proton-core-notification = { module = "me.proton.core:notification", version.ref = "proton-core" } +proton-core-observability = { module = "me.proton.core:observability", version.ref = "proton-core" } +proton-core-payment = { module = "me.proton.core:payment", version.ref = "proton-core" } +proton-core-paymentIap = { module = "me.proton.core:payment-iap", version.ref = "proton-core" } +proton-core-plan = { module = "me.proton.core:plan", version.ref = "proton-core" } +proton-core-plan-presentationCompose = { module = "me.proton.core:plan-presentation-compose", version.ref = "proton-core" } +proton-core-planTest = { module = "me.proton.core:plan-test", version.ref = "proton-core" } +proton-core-presentation = { module = "me.proton.core:presentation", version.ref = "proton-core" } +proton-core-presentationCompose = { module = "me.proton.core:presentation-compose", version.ref = "proton-core" } +proton-core-proguardRules = { module = "me.proton.core:proguard-rules", version.ref = "proton-core" } +proton-core-push = { module = "me.proton.core:push", version.ref = "proton-core" } +proton-core-report = { module = "me.proton.core:report", version.ref = "proton-core" } +proton-core-reportTest = { module = "me.proton.core:report-test", version.ref = "proton-core" } +proton-core-telemetry = { module = "me.proton.core:telemetry", version.ref = "proton-core" } +proton-core-user = { module = "me.proton.core:user", version.ref = "proton-core" } +proton-core-userRecovery = { module = "me.proton.core:user-recovery", version.ref = "proton-core" } +proton-core-userRecoveryTest = { module = "me.proton.core:user-recovery-test", version.ref = "proton-core" } +proton-core-userSettings = { module = "me.proton.core:user-settings", version.ref = "proton-core" } +proton-core-utilAndroidDagger = { module = "me.proton.core:util-android-dagger", version.ref = "proton-core" } +proton-core-utilAndroidSentry = { module = "me.proton.core:util-android-sentry", version.ref = "proton-core" } +proton-core-utilAndroidStrictMode = { module = "me.proton.core:util-android-strict-mode", version.ref = "proton-core" } +proton-core-testAndroid = { module = "me.proton.core:test-android", version.ref = "proton-core" } +proton-core-testKotlin = { module = "me.proton.core:test-kotlin", version.ref = "proton-core" } +proton-core-testQuark = { module = "me.proton.core:test-quark", version.ref = "proton-core" } +proton-core-testRule = { module = "me.proton.core:test-rule", version.ref = "proton-core" } +proton-core-testAndroidInstrumented = { module = "me.proton.core:test-android-instrumented", version.ref = "proton-core" } + +[bundles] +appLibs = [ + "androidx-activity-ktx", + "androidx-appcompat", + "androidx-biometrics", + "androidx-compose-runtime-livedata", + "androidx-core-splashscreen", + "androidx-hilt-navigation-compose", + "dagger-hilt-android", + "dagger-hilt-compiler", + "androidx-hilt-work", + "androidx-lifecycle-process", + "androidx-navigation-compose", + "androidx-paging-compose", + "androidx-paging-runtime", + "androidx-profileinstaller", + "androidx-room-ktx", + "androidx-work-runtimeKtx", + "android-material", + "arrow-core", + "timber", + "google-play-review", + "google-play-reviewKtx", + "kotlinx-immutableCollections", + "okhttp", + "plumber", + "sentry", + "sentry-compose", + "proton-core-proguardRules", + "proton-core-account", + "proton-core-accountManager", + "proton-core-accountRecovery", + "proton-core-auth", + "proton-core-authFido", + "proton-core-biometric", + "proton-core-challenge", + "proton-core-contact", + "proton-core-country", + "proton-core-crypto", + "proton-core-cryptoValidator", + "proton-core-data", + "proton-core-dataRoom", + "proton-core-deviceMigration", + "proton-core-domain", + "proton-core-eventManager", + "proton-core-featureFlag", + "proton-core-humanVerification", + "proton-core-key", + "proton-core-keyTransparency", + "proton-core-label", + "proton-core-mailSettings", + "proton-core-network", + "proton-core-notification", + "proton-core-observability", + "proton-core-payment", + "proton-core-paymentIap", + "proton-core-plan", + "proton-core-presentation", + "proton-core-presentationCompose", + "proton-core-push", + "proton-core-report", + "proton-core-telemetry", + "proton-core-user", + "proton-core-userRecovery", + "proton-core-userSettings", + "proton-core-configuration-data", + "proton-core-utilAndroidDagger", + "proton-core-utilAndroidSentry", + "proton-core-utilAndroidStrictMode", +] + +compose = [ + "androidx-activity-compose", + "androidx-compose-foundation", + "androidx-compose-foundation-layout", + "androidx-compose-material", + "androidx-compose-material3", + "androidx-compose-runtime", + "androidx-compose-runtime-livedata", + "androidx-compose-ui", + "androidx-compose-ui-tooling-preview", + "androidx-constraintlayout-compose" +] + +compose-debug = [ + "androidx-compose-ui-test-manifest", + "androidx-compose-ui-tooling", + "androidx-customview", + "androidx-customview-poolingcontainer" +] + +module-presentation = [ + "androidx-annotation", + "androidx-compose-material3", + "androidx-compose-ui-tooling-preview", + "arrow-core", + "dagger-hilt-android", + "javax-inject", + "kotlinx-immutableCollections", + "proton-core-domain", + "proton-core-presentation", + "proton-core-presentationCompose", + "timber" +] + +module-data = [ + "androidx-datastore-preferences", + "androidx-room-ktx", + "androidx-work-runtimeKtx", + "arrow-core", + "dagger-hilt-android", + "timber", + "javax-inject", + "kotlin-serialization-json", + "okhttp", + "retrofit" +] + +module-domain = [ + "arrow-core", + "dagger-hilt-android", + "timber", + "javax-inject", + "kotlin-coroutines-core" +] + +app-annotationProcessors = [ + "androidx-room-compiler", + "androidx-hilt-compiler", + "dagger-hilt-compiler" +] + +app-debug = [ + "leakcanary-android" +] + +test = [ + "cash-turbine", + "guava", + "junit", + "kotlin-test", + "kotlin-test-junit", + "kotlin-coroutines-test", + "mockk", + "proton-core-testAndroid", + "proton-core-testKotlin", + "proton-core-testQuark" +] + +test-androidTest = [ + "androidx-compose-ui-test", + "androidx-compose-ui-test-junit4", + "androidx-datastore-preferences", + "androidx-test-core", + "androidx-test-core-ktx", + "androidx-test-espresso-core", + "androidx-test-espresso-web", + "androidx-test-espresso-intents", + "androidx-test-monitor", + "androidx-test-rules", + "androidx-test-runner", + "androidx-test-uiautomator", + "cash-turbine", + "dagger-hilt-android-testing", + "kotlin-test", + "kotlin-test-junit", + "mockk-android", + "proton-core-testAndroidInstrumented", + "proton-core-testQuark" +] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023..2c3521197d 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9c96e40415..09523c0e54 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Oct 26 13:03:32 CEST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c81..f5feea6d6b 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,69 +15,104 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +122,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f938..9b42019c79 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/mail-bugreport/build.gradle.kts b/mail-bugreport/build.gradle.kts new file mode 100644 index 0000000000..704f1a6c47 --- /dev/null +++ b/mail-bugreport/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "ch.protonmail.android.mailbugreport" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } +} + +dependencies { + api(project(":mail-bugreport:dagger")) + api(project(":mail-bugreport:data")) + api(project(":mail-bugreport:domain")) + api(project(":mail-bugreport:presentation")) +} diff --git a/mail-bugreport/dagger/build.gradle.kts b/mail-bugreport/dagger/build.gradle.kts new file mode 100644 index 0000000000..071a9131cd --- /dev/null +++ b/mail-bugreport/dagger/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailbugreport.dagger" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.dagger.hilt.android) + implementation(libs.proton.core.report) + implementation(libs.kotlin.coroutines.core) + + implementation(project(":mail-bugreport:data")) + implementation(project(":mail-bugreport:domain")) + implementation(project(":mail-common:domain")) +} diff --git a/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt b/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt new file mode 100644 index 0000000000..d0bc590ad4 --- /dev/null +++ b/mail-bugreport/dagger/src/main/kotlin/ch/protonmail/android/mailbugreport/MailReportModule.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport + +import ch.protonmail.android.mailbugreport.data.LogsFileHandlerImpl +import ch.protonmail.android.mailbugreport.data.provider.BugReportLogProviderImpl +import ch.protonmail.android.mailbugreport.data.provider.LogcatProviderImpl +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue +import ch.protonmail.android.mailbugreport.domain.featureflags.IsLogsExportingFeatureEnabled +import ch.protonmail.android.mailbugreport.domain.featureflags.IsLogsExportingInternalFeatureEnabled +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider +import ch.protonmail.android.mailcommon.domain.AppInformation +import ch.protonmail.android.mailcommon.domain.isDevOrAlphaFlavor +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.proton.core.report.domain.provider.BugReportLogProvider +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object MailReportModule { + + @Provides + @Singleton + @LogsExportFeatureSettingValue + fun provideLogsExporting( + isEnabled: IsLogsExportingFeatureEnabled, + isInternalEnabled: IsLogsExportingInternalFeatureEnabled, + appInformation: AppInformation + ): LogsExportFeatureSetting { + return if (appInformation.isDevOrAlphaFlavor()) { + LogsExportFeatureSetting(enabled = true, internalEnabled = true) + } else + LogsExportFeatureSetting(enabled = isEnabled(), internalEnabled = isInternalEnabled()) + } + + @Module + @InstallIn(SingletonComponent::class) + internal interface BindsModule { + + @Binds + @Reusable + fun provideLogcatProvider(impl: LogcatProviderImpl): LogcatProvider + + @Binds + @Singleton + fun provideLogsFileHandler(impl: LogsFileHandlerImpl): LogsFileHandler + } +} + +@Module +@InstallIn(SingletonComponent::class) +interface CoreLogModule { + + @Binds + fun provideBugReportLogProvider(impl: BugReportLogProviderImpl): BugReportLogProvider +} diff --git a/mail-bugreport/data/build.gradle.kts b/mail-bugreport/data/build.gradle.kts new file mode 100644 index 0000000000..9f99fc9991 --- /dev/null +++ b/mail-bugreport/data/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") +} + +android { + namespace = "ch.protonmail.android.mailbugreport.data" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.bundles.module.data) + + implementation(libs.timber) + implementation(libs.proton.core.report) + + implementation(project(":mail-common:domain")) + implementation(project(":mail-bugreport:domain")) + + testImplementation(libs.bundles.test) +} diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt new file mode 100644 index 0000000000..6f19d173e8 --- /dev/null +++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/FileLoggingTree.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.data + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import android.util.Log +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import timber.log.Timber + +class FileLoggingTree(private val logsFileHandler: LogsFileHandler) : Timber.Tree() { + + init { + initNewSession() + } + + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable? + ) { + if (tag in ExcludedTags) return + val logMessage = createLogMessage(priority, tag, message) + logsFileHandler.writeLog(logMessage) + } + + private fun initNewSession() { + Timber.tag("FileLoggingTree").i( + """ + | + |---------- New Session ---------- + | + """.trimMargin() + ) + } + + private fun createLogMessage( + priority: Int, + tag: String?, + message: String + ): String { + val priorityTag = priorityChar(priority) + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date()) + return "$timestamp $priorityTag/$tag: $message" + } + + private fun priorityChar(priority: Int): Char { + return when (priority) { + Log.VERBOSE -> 'V' + Log.DEBUG -> 'D' + Log.INFO -> 'I' + Log.WARN -> 'W' + Log.ERROR -> 'E' + Log.ASSERT -> 'A' + else -> '?' + } + } + + private companion object { + + val ExcludedTags = listOf( + "core.network" + ) + } +} diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt new file mode 100644 index 0000000000..426a1c299c --- /dev/null +++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImpl.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.data + +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import android.content.Context +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +class LogsFileHandlerImpl @Inject constructor( + @ApplicationContext private val context: Context, + @IODispatcher private val coroutineDispatcher: CoroutineDispatcher +) : LogsFileHandler, CoroutineScope { + + override val coroutineContext: CoroutineContext = coroutineDispatcher + SupervisorJob() + + private val logDir by lazy { + File(context.cacheDir, LogsDirName) + .apply { mkdirs() } + } + + private var currentLogFile: File? = null + private var fileWriter: FileWriter? = null + + override fun getParentPath(): File = logDir + + override fun getLastLogFile(): File? = currentLogFile + + override fun writeLog(message: String) = launch { + runCatching { + (fileWriter ?: prepareFileWriter()).let { writer -> + writer.appendLine(message) + writer.flush() + } + + currentLogFile?.takeIf { it.length() > MaxFileSizeBytes }?.let { + rotateLogFiles() + } + }.onFailure { it.printStackTrace() } + }.let {} + + private fun prepareFileWriter(): FileWriter { + val files = logDir.listFiles().orEmpty().sortedBy { it.lastModified() } + + currentLogFile = when { + files.isEmpty() -> createNewFile() + files.last().length() > MaxFileSizeBytes -> createNewFile() + else -> files.last() + } + + return FileWriter(currentLogFile, true).also { fileWriter = it } + } + + private fun rotateLogFiles() { + val existingFiles = logDir.listFiles().orEmpty() + .sortedBy { it.lastModified() } + + if (existingFiles.size >= MaxLogFiles) { + existingFiles + .take(existingFiles.size - MaxLogFiles + 1) + .forEach { it.delete() } + } + + prepareFileWriter() + } + + private fun createNewFile(): File { + val date = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()).format(Date()) + val newFile = File(logDir, "log-$date.txt") + + runCatching { + newFile.createNewFile() + }.onFailure { it.printStackTrace() } + + return newFile + } + + override fun close() { + cancel() + runCatching { + fileWriter?.close() + }.onFailure { it.printStackTrace() } + } + + companion object { + + private const val LogsDirName = "logs" + private const val MaxFileSizeBytes = 2 * 1024 * 1024 // 2 MB + private const val MaxLogFiles = 3 + } +} diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt new file mode 100644 index 0000000000..8a7ee66c20 --- /dev/null +++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/BugReportProviderImpl.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.data.provider + +import java.io.File +import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile +import me.proton.core.report.domain.provider.BugReportLogProvider +import javax.inject.Inject + +class BugReportLogProviderImpl @Inject constructor( + private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile +) : BugReportLogProvider { + + override suspend fun getLog(): File? = getAggregatedEventsZipFile().getOrNull() + + override suspend fun releaseLog(log: File) = Unit +} diff --git a/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt new file mode 100644 index 0000000000..71c11d4bec --- /dev/null +++ b/mail-bugreport/data/src/main/kotlin/ch/protonmail/android/mailbugreport/data/provider/LogcatProviderImpl.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.data.provider + +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import android.content.Context +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProviderError +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +class LogcatProviderImpl @Inject constructor( + @ApplicationContext val context: Context +) : LogcatProvider { + + override fun getParentPath(): File = File(context.cacheDir, LogsDirName) + + @Suppress("TooGenericExceptionCaught") + override suspend fun getLogcatFile(): Either = withContext(Dispatchers.IO) { + runCatching { + val logDir = getParentPath() + if (!logDir.exists()) logDir.mkdirs() + + val packageName = context.packageName + val logFile = File(logDir, "logcat_$packageName.txt") + + val process = Runtime.getRuntime().exec( + arrayOf("logcat", "-d", "-v", "time", "*:V", "--t", getCutoffTimestamp()) + ) + + process.inputStream.bufferedReader().useLines { lines -> + logFile.bufferedWriter().use { writer -> + lines.forEach { line -> + writer.appendLine(line) + } + } + } + + logFile.right() + }.getOrElse { e -> + Timber.e(e, "Error dumping logcat") + LogcatProviderError.Error.left() + } + } + + private fun getCutoffTimestamp(): String { + val cutoffInstant = Instant.now().minusSeconds(CutOffTimePeriod) + val formatter = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.000").withZone(ZoneId.systemDefault()) + return formatter.format(cutoffInstant) + } + + private companion object { + + const val CutOffTimePeriod = 12 * 3600L // 12 hours + const val LogsDirName = "logcat" + } +} diff --git a/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt new file mode 100644 index 0000000000..aade262858 --- /dev/null +++ b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/BugReportProviderImplTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.data + +import java.io.File +import java.io.IOException +import ch.protonmail.android.mailbugreport.data.provider.BugReportLogProviderImpl +import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class BugReportProviderImplTest { + + private val getAggregatedEventsZipFile = mockk() + private val bugReportLogProviderImpl = BugReportLogProviderImpl(getAggregatedEventsZipFile) + + @Test + fun `should proxy the call to getAggregatedEventsZipFile and return the file when successful`() = runTest { + // Given + coEvery { getAggregatedEventsZipFile() } returns Result.success(File("")) + // When + val result = bugReportLogProviderImpl.getLog() + + // Then + assertNotNull(result) + coVerify(exactly = 1) { getAggregatedEventsZipFile() } + } + + @Test + fun `should proxy the call to getAggregatedEventsZipFile and return null when unsuccessful`() = runTest { + // Given + coEvery { getAggregatedEventsZipFile() } returns Result.failure(IOException()) + + // When + val result = bugReportLogProviderImpl.getLog() + + // Then + assertNull(result) + coVerify(exactly = 1) { getAggregatedEventsZipFile() } + } +} diff --git a/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt new file mode 100644 index 0000000000..1f81377b1d --- /dev/null +++ b/mail-bugreport/data/src/test/kotlin/ch/protonmail/android/mailbugreport/data/LogsFileHandlerImplTest.kt @@ -0,0 +1,106 @@ +package ch.protonmail.android.mailbugreport.data + +import java.io.File +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +internal class LogsFileHandlerImplTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var logsFileHandler: LogsFileHandlerImpl + private lateinit var testDispatcher: TestDispatcher + private lateinit var cacheDir: File + private lateinit var logDir: File + + @BeforeTest + fun setup() { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + + cacheDir = tempFolder.newFolder() + logDir = File(cacheDir, "logs").apply { mkdirs() } + + context = mockk { + every { cacheDir } returns this@LogsFileHandlerImplTest.cacheDir + } + + logsFileHandler = LogsFileHandlerImpl( + context = context, + coroutineDispatcher = testDispatcher + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `getParentPath returns correct directory`() { + // When + val parentPath = logsFileHandler.getParentPath() + + // Then + assertEquals("logs", parentPath.name) + assertTrue(parentPath.exists()) + assertTrue(parentPath.isDirectory) + } + + @Test + fun `writeLog creates new file when no files exist`() = runTest(testDispatcher) { + // When + logsFileHandler.writeLog("Test message") + advanceUntilIdle() + + val files = logDir.listFiles() + + // Then + assertNotNull(files) + assertEquals(1, files.size) + assertTrue(files[0].name.startsWith("log-")) + assertTrue(files[0].readText().contains("Test message")) + } + + @Test + fun `calling close properly closes file writer and cancels coroutine scope`() = runTest(testDispatcher) { + // When + logsFileHandler.writeLog("Test message") + advanceUntilIdle() + + logsFileHandler.close() + + // Write after close, won't create new content + logsFileHandler.writeLog("After close") + advanceUntilIdle() + + val files = logDir.listFiles() + + // Then + assertNotNull(files) + assertEquals(1, files.size) + assertTrue(files[0].readText().contains("Test message")) + assertFalse(files[0].readText().contains("After close")) + } +} diff --git a/mail-bugreport/domain/build.gradle.kts b/mail-bugreport/domain/build.gradle.kts new file mode 100644 index 0000000000..ed42b498eb --- /dev/null +++ b/mail-bugreport/domain/build.gradle.kts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") +} + +android { + namespace = "ch.protonmail.android.mailpagination.domain" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + implementation(libs.bundles.module.domain) + implementation(libs.proton.core.featureFlag) + + testImplementation(libs.bundles.test) +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt new file mode 100644 index 0000000000..8c3bb81211 --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsExportFeatureSetting.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain + +/** + * Holds the feature flag configurations for the "Application logs" feature accessible from the Settings. + * + * @param enabled Enables the "Application logs" section in the settings and logs application events + * to a file in the internal app storage (cache) during the application lifecycle. + * Moreover, it allows users to attach logs when reporting an issue via the "Report a Problem" screen, + * accessible from the Sidebar menu. + * + * @param internalEnabled Enables the intermediate "Application logs" screen and also allows exporting logcat data + * from the device in use. This is enabled and available only for the internal QA team to provide logs + * to the development team while testing features in development. + * + * Note: both settings are enabled by default regardless of the feature flags in non-production builds. + */ +data class LogsExportFeatureSetting( + private val enabled: Boolean, + private val internalEnabled: Boolean +) { + + val isEnabled = enabled || internalEnabled + val isInternalFeatureEnabled = internalEnabled +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt new file mode 100644 index 0000000000..d115c1908f --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/LogsFileHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain + +import java.io.File + +interface LogsFileHandler { + + fun getParentPath(): File + fun getLastLogFile(): File? + fun writeLog(message: String) + fun close() +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt new file mode 100644 index 0000000000..88c8fb0b05 --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/annotations/LogsExportingFeatureEnabled.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.annotations + +import javax.inject.Qualifier + +/** + * Provider for the "Application logs" related feature flags. + */ +@Qualifier +annotation class LogsExportFeatureSettingValue diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt new file mode 100644 index 0000000000..b375723ca4 --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingFeatureEnabled.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.featureflags + +import me.proton.core.featureflag.domain.ExperimentalProtonFeatureFlag +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureId +import javax.inject.Inject + +class IsLogsExportingFeatureEnabled @Inject constructor( + private val featureFlagManager: FeatureFlagManager +) { + + @OptIn(ExperimentalProtonFeatureFlag::class) + operator fun invoke() = featureFlagManager.getValue(null, FeatureId(FeatureFlagId)) + + private companion object { + + const val FeatureFlagId = "MailAndroidLogsExportingFeature" + } +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt new file mode 100644 index 0000000000..70c5fe85e4 --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/featureflags/IsLogsExportingInternalFeatureEnabled.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.featureflags + +import me.proton.core.featureflag.domain.ExperimentalProtonFeatureFlag +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureId +import javax.inject.Inject + +class IsLogsExportingInternalFeatureEnabled @Inject constructor( + private val featureFlagManager: FeatureFlagManager +) { + + @OptIn(ExperimentalProtonFeatureFlag::class) + operator fun invoke() = featureFlagManager.getValue(null, FeatureId(FeatureFlagId)) + + private companion object { + + const val FeatureFlagId = "MailAndroidLogsExportingInternalFeature" + } +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt new file mode 100644 index 0000000000..d0020ef24f --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/provider/LogcatProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.provider + +import java.io.File +import arrow.core.Either + +interface LogcatProvider { + + suspend fun getLogcatFile(): Either + fun getParentPath(): File +} + +interface LogcatProviderError { + data object Error : LogcatProviderError +} diff --git a/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt new file mode 100644 index 0000000000..4a18ce2567 --- /dev/null +++ b/mail-bugreport/domain/src/main/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFile.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.usecase + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import android.content.Context +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailbugreport.domain.annotations.LogsExportFeatureSettingValue +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class GetAggregatedEventsZipFile @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val logcatProvider: LogcatProvider, + private val logsFileHandler: LogsFileHandler, + @LogsExportFeatureSettingValue private val logsExportFeatureSetting: LogsExportFeatureSetting +) { + + suspend operator fun invoke() = withContext(Dispatchers.IO) { + runCatching { + val directoriesList = buildDirectoriesList(logsExportFeatureSetting) + val outputFile = File(applicationContext.cacheDir, FilePath).also { + it.mkdirs() + if (it.exists()) it.delete() + } + + FileOutputStream(outputFile).use { fos -> + ZipOutputStream(fos).use { zos -> + directoriesList.forEach { file -> + zipFileOrDirectory(file, zos) + } + } + } + outputFile + } + } + + private fun zipFileOrDirectory( + file: File, + zos: ZipOutputStream, + baseName: String = "" + ) { + if (file.isDirectory) { + file.listFiles()?.forEach { child -> + zipFileOrDirectory(child, zos, "$baseName${file.name}/") + } + } else { + FileInputStream(file).use { fis -> + val entryName = "$baseName${file.name}" + zos.putNextEntry(ZipEntry(entryName)) + fis.copyTo(zos, bufferSize = 1024) + } + } + } + + private suspend fun buildDirectoriesList(logsExportFeatureSetting: LogsExportFeatureSetting): List { + return buildList { + // Attaching logcat is only for internal QA, this is not enabled for end users. + if (logsExportFeatureSetting.isInternalFeatureEnabled) { + logcatProvider.getLogcatFile() + add(logcatProvider.getParentPath()) + } + add(logsFileHandler.getParentPath()) + } + } + + private companion object { + + const val FilePath = "export_logs/protonmail_events.zip" + } +} diff --git a/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt b/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt new file mode 100644 index 0000000000..d4b06553b6 --- /dev/null +++ b/mail-bugreport/domain/src/test/kotlin/ch/protonmail/android/mailbugreport/domain/usecase/GetAggregatedEventsZipFileTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.domain.usecase + +import java.io.File +import android.content.Context +import ch.protonmail.android.mailbugreport.domain.LogsExportFeatureSetting +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider +import io.mockk.called +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Before +import javax.inject.Provider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetAggregatedEventsZipFileTest { + + private val context: Context = mockk(relaxed = true) + private val logcatProvider = mockk() + private val logsFileHandler = mockk() + private val logsExportFeatureSetting = mockk> { + every { this@mockk.get() } returns DefaultExportSettings + } + private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile + get() = GetAggregatedEventsZipFile(context, logcatProvider, logsFileHandler, logsExportFeatureSetting.get()) + + @Before + fun setup() { + unmockkAll() + val tempCacheDir = File(System.getProperty("java.io.tmpdir"), "test-cache") + .apply { mkdirs() } + every { context.cacheDir } returns tempCacheDir + } + + @Test + fun `invoke creates zip file with correct name`() = runTest { + // Arrange + val mockLogcatDir = mockk() + val mockLogsDir = mockk() + + coEvery { logcatProvider.getLogcatFile() } returns mockk() + every { logcatProvider.getParentPath() } returns mockLogcatDir + every { logsFileHandler.getParentPath() } returns mockLogsDir + every { mockLogcatDir.isDirectory } returns true + every { mockLogsDir.isDirectory } returns true + every { mockLogcatDir.listFiles() } returns emptyArray() + every { mockLogsDir.listFiles() } returns emptyArray() + + // Act + val result = getAggregatedEventsZipFile() + + // Assert + assertTrue(result.isSuccess) + result.getOrNull()?.let { zipFile -> + assertEquals("protonmail_events.zip", zipFile.name) + assertTrue(zipFile.exists()) + } + } + + @Test + fun `invoke skips logcat when the internal feature flag is disabled`() = runTest { + // Arrange + val mockLogsDir = mockk() + + every { logsExportFeatureSetting.get() } returns + LogsExportFeatureSetting(enabled = true, internalEnabled = false) + + every { logsFileHandler.getParentPath() } returns mockLogsDir + every { mockLogsDir.isDirectory } returns true + every { mockLogsDir.listFiles() } returns emptyArray() + + // Act + val result = getAggregatedEventsZipFile() + + // Assert + assertTrue(result.isSuccess) + result.getOrNull()?.let { zipFile -> + assertEquals("protonmail_events.zip", zipFile.name) + assertTrue(zipFile.exists()) + } + verify { logcatProvider wasNot called } + } + + @Test + fun `invoke fails when logcat directory does not exist`() = runTest { + // Arrange + val mockLogcatDir = mockk() + val mockLogsDir = mockk() + + every { logcatProvider.getParentPath() } returns mockLogcatDir + every { logsFileHandler.getParentPath() } returns mockLogsDir + every { mockLogcatDir.exists() } returns false + + // Act + val result = getAggregatedEventsZipFile() + + // Assert + assertTrue(result.isFailure) + } + + @Test + fun `invoke fails when events directory does not exist`() = runTest { + // Arrange + val mockLogcatDir = mockk() + val mockLogsDir = mockk() + + every { logcatProvider.getParentPath() } returns mockLogcatDir + every { logsFileHandler.getParentPath() } returns mockLogsDir + every { mockLogcatDir.exists() } returns true + every { mockLogsDir.exists() } returns false + + // Act + val result = getAggregatedEventsZipFile() + + // Assert + assertTrue(result.isFailure) + } + + private companion object { + + val DefaultExportSettings = LogsExportFeatureSetting(enabled = true, internalEnabled = true) + } +} + diff --git a/mail-bugreport/presentation/build.gradle.kts b/mail-bugreport/presentation/build.gradle.kts new file mode 100644 index 0000000000..fa769f8359 --- /dev/null +++ b/mail-bugreport/presentation/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") + id("dagger.hilt.android.plugin") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "ch.protonmail.android.mailbugreport.presentation" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + compose = true + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + debugImplementation(libs.bundles.compose.debug) + + implementation(libs.bundles.module.presentation) + implementation(libs.dagger.hilt.android) + + implementation(project(":mail-bugreport:domain")) + implementation(project(":mail-common:presentation")) + + testImplementation(libs.bundles.test) +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt new file mode 100644 index 0000000000..ade4b925f0 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsFileUiModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +import java.io.File +import androidx.compose.runtime.Stable + +@Stable +data class ApplicationLogsFileUiModel( + val rawFile: File, + val fileName: String, + val fileContents: List +) diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt new file mode 100644 index 0000000000..14f5061d8e --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsOperation.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +import java.io.File + +sealed interface ApplicationLogsOperation { + + sealed interface ApplicationLogsAction : ApplicationLogsOperation { + + sealed interface Export : ApplicationLogsAction { + data object ShareLogs : Export + data object ExportLogs : Export + } + + sealed interface View : ApplicationLogsAction { + data object ViewLogcat : View + data object ViewEvents : View + } + } + + sealed interface ApplicationLogsEvent : ApplicationLogsOperation { + sealed interface Export : ApplicationLogsEvent { + data class ShareReady(val file: File) : Export + data class ExportReady(val file: File) : Export + } + + sealed interface View : ApplicationLogsEvent { + data object LogcatReady : View + data object EventsReady : View + } + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt new file mode 100644 index 0000000000..1946ad8e54 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsPeekViewAction.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +import java.io.File + +sealed interface ApplicationLogsPeekViewOperation { + + sealed interface ViewAction : ApplicationLogsPeekViewOperation { + data object DisplayFileContent : ViewAction + } + + sealed interface ViewEvent : ApplicationLogsPeekViewOperation { + data class FileContentLoaded(val file: File) : ViewEvent + data object FileContentLoadError : ViewEvent + data object InvalidOpenMode : ViewEvent + data object Loading : ViewEvent + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt new file mode 100644 index 0000000000..e07130cf5f --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +import java.io.File +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel + +data class ApplicationLogsState( + val error: Effect, + val showApplicationLogs: Effect, + val showLogcat: Effect, + val share: Effect, + val export: Effect +) diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt new file mode 100644 index 0000000000..ec30b56f9e --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewItemMode.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface ApplicationLogsViewItemMode { + + @Serializable + data object Logcat : ApplicationLogsViewItemMode + + @Serializable + data object Events : ApplicationLogsViewItemMode +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt new file mode 100644 index 0000000000..9cc8c3e6e6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/model/ApplicationLogsViewState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.model + +sealed interface ApplicationLogsPeekViewState { + data object Loading : ApplicationLogsPeekViewState + data class Loaded(val uiModel: ApplicationLogsFileUiModel) : ApplicationLogsPeekViewState + data object Error : ApplicationLogsPeekViewState +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt new file mode 100644 index 0000000000..a4ec62ff20 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekView.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewOperation +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState +import ch.protonmail.android.mailbugreport.presentation.viewmodel.ApplicationLogsPeekViewViewModel +import me.proton.core.compose.component.ProtonCenteredProgress + +@Composable +fun ApplicationLogsPeekView(onBack: () -> Unit) { + + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + when (state) { + is ApplicationLogsPeekViewState.Loaded -> ApplicationLogsPeekViewContent( + state as ApplicationLogsPeekViewState.Loaded, + onBack + ) + + ApplicationLogsPeekViewState.Loading -> ProtonCenteredProgress() + ApplicationLogsPeekViewState.Error -> ApplicationLogsPeekViewError(onBack) + } + + LaunchedEffect(Unit) { + viewModel.submit(ApplicationLogsPeekViewOperation.ViewAction.DisplayFileContent) + } +} + +object ApplicationLogsPeekView { + + const val ApplicationLogsViewMode = "application_logs_view_mode" +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt new file mode 100644 index 0000000000..02c0e1bb3c --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewContent.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState +import ch.protonmail.android.mailbugreport.presentation.utils.ApplicationLogsUtils.shareLogs +import kotlinx.coroutines.launch +import me.proton.core.compose.component.appbar.ProtonTopAppBar +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme + +@Composable +internal fun ApplicationLogsPeekViewContent( + state: ApplicationLogsPeekViewState.Loaded, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val context = LocalContext.current + + val contents = state.uiModel.fileContents + + Scaffold( + modifier = modifier, + topBar = { + ProtonTopAppBar( + modifier = modifier.fillMaxWidth(), + title = { + Text( + modifier = Modifier.clickable { + coroutineScope.launch { lazyListState.animateScrollToItem(0) } + }, + text = state.uiModel.fileName, + overflow = TextOverflow.Ellipsis, + color = ProtonTheme.colors.textNorm + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + tint = ProtonTheme.colors.iconNorm, + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = { coroutineScope.launch { lazyListState.animateScrollToItem(contents.size) } } + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + tint = ProtonTheme.colors.iconNorm, + contentDescription = null + ) + } + IconButton( + onClick = { + coroutineScope.launch { context.shareLogs(state.uiModel.rawFile) } + } + ) { + Icon(Icons.Filled.Share, tint = ProtonTheme.colors.iconNorm, contentDescription = null) + } + } + ) + } + ) { contentPadding -> + Box(modifier = Modifier.padding(contentPadding)) { + SelectionContainer { + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .padding(ProtonDimens.ExtraSmallSpacing) + .animateContentSize() + ) { + // Load in chunks as the file contents could be huge and take a while to render. + items(state.uiModel.fileContents) { chunk -> + Text(text = chunk, fontSize = 12.sp) + } + } + } + } + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt new file mode 100644 index 0000000000..ed70bbe6de --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsPeekViewError.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import me.proton.core.presentation.utils.showToast + +@Composable +internal fun ApplicationLogsPeekViewError(onBack: () -> Unit) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + context.showToast("Unable to retrieve file contents.") + } + + onBack() +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt new file mode 100644 index 0000000000..ffbb80777e --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreen.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.ui + +import java.io.File +import java.io.IOException +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailbugreport.presentation.R +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation.ApplicationLogsAction.Export +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation.ApplicationLogsAction.View +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode +import ch.protonmail.android.mailbugreport.presentation.utils.ApplicationLogsUtils.shareLogs +import ch.protonmail.android.mailbugreport.presentation.viewmodel.ApplicationLogsViewModel +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import me.proton.core.compose.component.ProtonSettingsTopBar +import me.proton.core.presentation.utils.showToast +import timber.log.Timber + +@Composable +fun ApplicationLogsScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onViewItemClick: (ApplicationLogsViewItemMode) -> Unit, + viewModel: ApplicationLogsViewModel = hiltViewModel() +) { + val scaffoldState = rememberScaffoldState() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var file by remember { mutableStateOf(File("")) } + + val fileSaveLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + file.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: IOException) { + Timber.e("FileSave", "Error copying file", e) + } + } + } + + val actions = ApplicationLogsScreenList.Actions( + onExport = { viewModel.submit(Export.ExportLogs) }, + onShare = { viewModel.submit(Export.ShareLogs) }, + onShowLogcat = { viewModel.submit(View.ViewLogcat) }, + onShowEvents = { viewModel.submit(View.ViewEvents) } + ) + + ConsumableLaunchedEffect(state.showApplicationLogs) { + onViewItemClick(ApplicationLogsViewItemMode.Events) + } + + ConsumableLaunchedEffect(state.showLogcat) { + onViewItemClick(ApplicationLogsViewItemMode.Logcat) + } + + ConsumableLaunchedEffect(state.share) { + context.shareLogs(it) + } + + ConsumableLaunchedEffect(state.export) { + file = it + fileSaveLauncher.launch(it.name) + } + + ConsumableTextEffect(state.error) { message -> + context.showToast(message) + } + + Scaffold( + modifier = modifier, + scaffoldState = scaffoldState, + topBar = { + ProtonSettingsTopBar( + title = stringResource(R.string.application_events_title), + onBackClick = onBackClick + ) + }, + content = { paddingValues -> + ApplicationLogsScreenList(Modifier.padding(paddingValues), actions) + } + ) +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt new file mode 100644 index 0000000000..8e0fe92081 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/ui/ApplicationLogsScreenList.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import ch.protonmail.android.mailbugreport.presentation.R +import me.proton.core.compose.component.ProtonSettingsHeader +import me.proton.core.compose.component.ProtonSettingsItem +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme + +@Composable +internal fun ApplicationLogsScreenList(modifier: Modifier = Modifier, actions: ApplicationLogsScreenList.Actions) { + LazyColumn(modifier = modifier) { + item { + ProtonSettingsHeader( + title = stringResource(R.string.application_events_header_view), + modifier = Modifier.padding(bottom = ProtonDimens.SmallSpacing) + ) + } + item { + ProtonSettingsItem( + name = stringResource(R.string.application_events_view_logcat), + hint = stringResource(R.string.application_events_view_logcat_hint), + onClick = actions.onShowLogcat + ) + } + item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) } + item { + ProtonSettingsItem( + name = stringResource(R.string.application_events_view_events), + hint = stringResource(R.string.application_events_view_events_hint), + onClick = actions.onShowEvents + ) + } + item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) } + + item { ProtonSettingsHeader(title = "Export", modifier = Modifier.padding(bottom = ProtonDimens.SmallSpacing)) } + item { + ProtonSettingsItem( + name = stringResource(R.string.application_events_share), + onClick = actions.onShare + ) + } + item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) } + item { + ProtonSettingsItem( + name = stringResource(R.string.application_events_save_to_disk), + onClick = actions.onExport + ) + } + item { HorizontalDivider(color = ProtonTheme.colors.separatorNorm) } + } +} + +object ApplicationLogsScreenList { + data class Actions( + val onExport: () -> Unit, + val onShare: () -> Unit, + val onShowEvents: () -> Unit, + val onShowLogcat: () -> Unit + ) { + + companion object { + + val Empty = Actions( + onExport = {}, + onShare = {}, + onShowEvents = {}, + onShowLogcat = {} + ) + } + } +} + +@Preview +@Composable +private fun ApplicationLogsScreenPreview() { + ProtonTheme { + ApplicationLogsScreenList(actions = ApplicationLogsScreenList.Actions.Empty) + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt new file mode 100644 index 0000000000..c590dfa115 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/utils/ApplicationLogsUtils.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.utils + +import java.io.File +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal object ApplicationLogsUtils { + private const val FileProviderSuffix = ".logsfileprovider" + + suspend fun Context.shareLogs(file: File) { + val fileUri = withContext(Dispatchers.IO) { + FileProvider.getUriForFile( + this@shareLogs, + "$packageName$FileProviderSuffix", + file + ) + } + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + clipData = ClipData.newRawUri("", fileUri) + putExtra(Intent.EXTRA_STREAM, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + startActivity(Intent.createChooser(shareIntent, "Share File")) + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt new file mode 100644 index 0000000000..5821341db5 --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsPeekViewViewModel.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.viewmodel + +import java.io.File +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailbugreport.domain.LogsFileHandler +import ch.protonmail.android.mailbugreport.domain.provider.LogcatProvider +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsFileUiModel +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewOperation +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsPeekViewState +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsViewItemMode +import ch.protonmail.android.mailbugreport.presentation.ui.ApplicationLogsPeekView +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.proton.core.util.kotlin.deserialize +import javax.inject.Inject + +@HiltViewModel +class ApplicationLogsPeekViewViewModel @Inject constructor( + private val logsFileHandler: LogsFileHandler, + private val logcatProvider: LogcatProvider, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val openMode = savedStateHandle + .get(ApplicationLogsPeekView.ApplicationLogsViewMode) + ?.deserialize() + + private val mutableState = MutableStateFlow(ApplicationLogsPeekViewState.Loading) + val state = mutableState.asStateFlow() + + fun submit(action: ApplicationLogsPeekViewOperation.ViewAction) { + viewModelScope.launch { + when (action) { + ApplicationLogsPeekViewOperation.ViewAction.DisplayFileContent -> handleItemDisplay(openMode) + } + } + } + + private suspend fun handleItemDisplay(openMode: ApplicationLogsViewItemMode?) { + emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.Loading) + openMode ?: return emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.InvalidOpenMode) + + val file = when (openMode) { + ApplicationLogsViewItemMode.Events -> logsFileHandler.getLastLogFile() + ApplicationLogsViewItemMode.Logcat -> logcatProvider.getLogcatFile().getOrNull() + } + ?.takeIf { withContext(Dispatchers.IO) { it.exists() } } + ?: return emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoadError) + + emitNewStateFromEvent(ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoaded(file)) + } + + private suspend fun emitNewStateFromEvent(event: ApplicationLogsPeekViewOperation.ViewEvent) { + when (event) { + is ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoaded -> mutableState.update { + ApplicationLogsPeekViewState.Loaded(event.file.toUiModel()) + } + + ApplicationLogsPeekViewOperation.ViewEvent.FileContentLoadError, + ApplicationLogsPeekViewOperation.ViewEvent.InvalidOpenMode -> { + mutableState.update { ApplicationLogsPeekViewState.Error } + } + + ApplicationLogsPeekViewOperation.ViewEvent.Loading -> mutableState.update { + ApplicationLogsPeekViewState.Loading + } + } + } + + private suspend fun File.toUiModel() = withContext(Dispatchers.IO) { + val chunkedContents = readLines().chunked(ChunkLines).map { it.joinToString(separator = "\n") } + ApplicationLogsFileUiModel(this@toUiModel, name, chunkedContents) + } + + private companion object { + const val ChunkLines = 200 + } +} diff --git a/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt new file mode 100644 index 0000000000..e2b93e1abc --- /dev/null +++ b/mail-bugreport/presentation/src/main/kotlin/ch/protonmail/android/mailbugreport/presentation/viewmodel/ApplicationLogsViewModel.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailbugreport.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailbugreport.domain.usecase.GetAggregatedEventsZipFile +import ch.protonmail.android.mailbugreport.presentation.R +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsOperation +import ch.protonmail.android.mailbugreport.presentation.model.ApplicationLogsState +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class ApplicationLogsViewModel @Inject constructor( + private val getAggregatedEventsZipFile: GetAggregatedEventsZipFile +) : ViewModel() { + + private val mutableState = MutableStateFlow( + ApplicationLogsState( + error = Effect.empty(), + showApplicationLogs = Effect.empty(), + showLogcat = Effect.empty(), + share = Effect.empty(), + export = Effect.empty() + ) + ) + + val state: StateFlow = mutableState.asStateFlow() + + fun submit(action: ApplicationLogsOperation.ApplicationLogsAction) { + viewModelScope.launch { + when (action) { + is ApplicationLogsOperation.ApplicationLogsAction.Export -> handleExportAction(action) + is ApplicationLogsOperation.ApplicationLogsAction.View -> handleViewAction(action) + } + } + } + + private suspend fun handleExportAction(action: ApplicationLogsOperation.ApplicationLogsAction.Export) { + val zipFile = withContext(Dispatchers.IO) { getAggregatedEventsZipFile() }.getOrElse { + mutableState.value = mutableState.value.copy( + error = Effect.of(TextUiModel.TextRes(R.string.application_events_export_error)) + ) + return + } + + when (action) { + ApplicationLogsOperation.ApplicationLogsAction.Export.ExportLogs -> + emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.Export.ExportReady(zipFile)) + + ApplicationLogsOperation.ApplicationLogsAction.Export.ShareLogs -> + emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.Export.ShareReady(zipFile)) + } + } + + private fun handleViewAction(action: ApplicationLogsOperation.ApplicationLogsAction.View) { + when (action) { + ApplicationLogsOperation.ApplicationLogsAction.View.ViewEvents -> + emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.View.EventsReady) + + ApplicationLogsOperation.ApplicationLogsAction.View.ViewLogcat -> + emitNewStateFromEvent(ApplicationLogsOperation.ApplicationLogsEvent.View.LogcatReady) + } + } + + private fun emitNewStateFromEvent(event: ApplicationLogsOperation.ApplicationLogsEvent) { + when (event) { + is ApplicationLogsOperation.ApplicationLogsEvent.Export.ShareReady -> { + mutableState.update { mutableState.value.copy(share = Effect.of(event.file)) } + } + + is ApplicationLogsOperation.ApplicationLogsEvent.Export.ExportReady -> { + mutableState.update { mutableState.value.copy(export = Effect.of(event.file)) } + } + + ApplicationLogsOperation.ApplicationLogsEvent.View.EventsReady -> { + mutableState.update { mutableState.value.copy(showApplicationLogs = Effect.of(Unit)) } + } + + ApplicationLogsOperation.ApplicationLogsEvent.View.LogcatReady -> { + mutableState.update { mutableState.value.copy(showLogcat = Effect.of(Unit)) } + } + } + } +} diff --git a/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml b/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-be/strings.xml b/mail-bugreport/presentation/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-be/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ca/strings.xml b/mail-bugreport/presentation/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ca/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-cs/strings.xml b/mail-bugreport/presentation/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-cs/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-da/strings.xml b/mail-bugreport/presentation/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-da/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-de/strings.xml b/mail-bugreport/presentation/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-de/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-el/strings.xml b/mail-bugreport/presentation/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-el/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml b/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-fi/strings.xml b/mail-bugreport/presentation/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-fi/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-fr/strings.xml b/mail-bugreport/presentation/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-fr/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-hi/strings.xml b/mail-bugreport/presentation/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-hi/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-hr/strings.xml b/mail-bugreport/presentation/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-hr/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-hu/strings.xml b/mail-bugreport/presentation/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-hu/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-in/strings.xml b/mail-bugreport/presentation/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-in/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-it/strings.xml b/mail-bugreport/presentation/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-it/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ja/strings.xml b/mail-bugreport/presentation/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ja/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ka/strings.xml b/mail-bugreport/presentation/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ka/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-kab/strings.xml b/mail-bugreport/presentation/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-kab/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ko/strings.xml b/mail-bugreport/presentation/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ko/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml b/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-nl/strings.xml b/mail-bugreport/presentation/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-nl/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-pl/strings.xml b/mail-bugreport/presentation/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-pl/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml b/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml b/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ro/strings.xml b/mail-bugreport/presentation/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ro/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-ru/strings.xml b/mail-bugreport/presentation/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-ru/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-sk/strings.xml b/mail-bugreport/presentation/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-sk/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-sl/strings.xml b/mail-bugreport/presentation/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-sl/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml b/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-tr/strings.xml b/mail-bugreport/presentation/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-tr/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-uk/strings.xml b/mail-bugreport/presentation/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-uk/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml b/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml b/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..6dda6805c6 --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-bugreport/presentation/src/main/res/values/strings.xml b/mail-bugreport/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..0fe7a427bc --- /dev/null +++ b/mail-bugreport/presentation/src/main/res/values/strings.xml @@ -0,0 +1,37 @@ + + + + + Application logs + + View logs + View logcat + + Display a dump of the logcat events + View application events + Display the latest application events + + Export + Share logs + Save logs to disk + An error occurred while exporting the events + \ No newline at end of file diff --git a/mail-bugreport/src/main/AndroidManifest.xml b/mail-bugreport/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..dbeff4a09b --- /dev/null +++ b/mail-bugreport/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-common/build.gradle.kts b/mail-common/build.gradle.kts new file mode 100644 index 0000000000..d68c90a05e --- /dev/null +++ b/mail-common/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "ch.protonmail.android.mailcommon" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } +} + +dependencies { + api(project(":mail-common:dagger")) + api(project(":mail-common:data")) + api(project(":mail-common:domain")) + api(project(":mail-common:presentation")) +} diff --git a/mail-common/dagger/build.gradle.kts b/mail-common/dagger/build.gradle.kts new file mode 100644 index 0000000000..8a42a81d72 --- /dev/null +++ b/mail-common/dagger/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailcommon.dagger" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.kotlin.coroutines.core) + implementation(libs.proton.core.label) + implementation(libs.dagger.hilt.android) + + implementation(project(":mail-common:data")) + implementation(project(":mail-common:domain")) + implementation(project(":mail-common:presentation")) +} diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt new file mode 100644 index 0000000000..8c2c57edc7 --- /dev/null +++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonDataModule.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.dagger + +import ch.protonmail.android.mailcommon.data.repository.UndoableOperationInMemoryRepository +import ch.protonmail.android.mailcommon.data.system.BuildVersionProviderImpl +import ch.protonmail.android.mailcommon.data.system.ContentValuesProviderImpl +import ch.protonmail.android.mailcommon.data.system.DeviceCapabilitiesImpl +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider +import ch.protonmail.android.mailcommon.domain.system.ContentValuesProvider +import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module(includes = [MailCommonDataModule.BindsModule::class]) +@InstallIn(SingletonComponent::class) +object MailCommonDataModule { + + @Module + @InstallIn(SingletonComponent::class) + internal interface BindsModule { + + @Binds + fun bindDeviceCapabilities(impl: DeviceCapabilitiesImpl): DeviceCapabilities + + @Binds + fun bindBuildVersionProvider(impl: BuildVersionProviderImpl): BuildVersionProvider + + @Binds + fun bindContentValuesProvider(impl: ContentValuesProviderImpl): ContentValuesProvider + + @Binds + @Singleton + fun bindUndoableOperationRepository(impl: UndoableOperationInMemoryRepository): UndoableOperationRepository + } +} diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt new file mode 100644 index 0000000000..b971f766c3 --- /dev/null +++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/MailCommonModule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.dagger + +import android.content.Context +import ch.protonmail.android.mailcommon.data.dagger.MailCommonDataModule +import ch.protonmail.android.mailcommon.data.repository.AppLocaleRepositoryImpl +import ch.protonmail.android.mailcommon.domain.coroutines.AppScope +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher +import ch.protonmail.android.mailcommon.domain.coroutines.MainDispatcher +import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import javax.inject.Singleton + +@Module(includes = [MailCommonDataModule::class]) +@InstallIn(SingletonComponent::class) +object MailCommonModule { + + @Provides + @DefaultDispatcher + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @IODispatcher + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @MainDispatcher + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @Provides + @Singleton + fun provideAppLocaleRepository(@ApplicationContext context: Context): AppLocaleRepository = + AppLocaleRepositoryImpl(context) + + @Provides + @Singleton + @AppScope + fun provideAppScope(@DefaultDispatcher dispatcher: CoroutineDispatcher) = CoroutineScope(dispatcher) +} diff --git a/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt new file mode 100644 index 0000000000..79d7047f75 --- /dev/null +++ b/mail-common/dagger/src/main/kotlin/ch/protonmail/android/mailcommon/dagger/NotificationCommonModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.dagger + +import android.app.NotificationManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object NotificationCommonModule { + + @Provides + fun provideNotificationManager(@ApplicationContext context: Context): NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + +} diff --git a/mail-common/data/build.gradle.kts b/mail-common/data/build.gradle.kts new file mode 100644 index 0000000000..0adf2888ff --- /dev/null +++ b/mail-common/data/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") +} + +android { + namespace = "ch.protonmail.android.mailcommon.data" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + + implementation(libs.bundles.module.data) + implementation(libs.androidx.appcompat) + + implementation(libs.proton.core.account.data) + implementation(libs.proton.core.featureFlag) + implementation(libs.proton.core.label.data) + implementation(libs.proton.core.label.domain) + implementation(libs.proton.core.user) + + implementation(project(":mail-common:domain")) + + testImplementation(libs.bundles.test) + testImplementation(project(":test:utils")) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt new file mode 100644 index 0000000000..824b611f15 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/db/dao/BaseDaoExtensions.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.db.dao + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.model.DaoError +import me.proton.core.data.room.db.BaseDao +import timber.log.Timber + +suspend fun BaseDao.upsertOrError(vararg entities: T): Either = either { + runCatching { + insertOrUpdate(*entities) + }.onFailure { + Timber.d("Error when performing upsertOrError - ${it::class.java} - ${it.message}") + + raise(DaoError.UpsertError(it)) + } +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt new file mode 100644 index 0000000000..2562a67b22 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelper.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import me.proton.core.util.kotlin.DispatcherProvider +import timber.log.Timber +import javax.inject.Inject + +class FileHelper @Inject constructor( + private val fileStreamFactory: FileStreamFactory, + private val fileFactory: FileFactory, + private val dispatcherProvider: DispatcherProvider, + @ApplicationContext private val applicationContext: Context +) { + + suspend fun readFromFile(folder: Folder, filename: Filename): String? = fileOperationIn(folder) { + val fileToRead = fileFactory.fileFrom(folder, filename) + runCatching { + fileStreamFactory.inputStreamFrom(fileToRead) + .bufferedReader() + .use { it.readText() } + }.getOrNull() + } + + suspend fun getFile(folder: Folder, filename: Filename): File? = fileOperationIn(folder) { + runCatching { fileFactory.fileFrom(folder, filename).takeIf { it.exists() } }.getOrNull() + } + + suspend fun getFolder(folderName: Folder): File? = fileOperationIn(folderName) { + runCatching { fileFactory.folderFrom(folderName) }.getOrNull() + } + + suspend fun renameFolder(oldFolder: Folder, newFolder: Folder) = fileOperationIn(newFolder) { + runCatching { + fileFactory.folderFromWhenExists(oldFolder)?.renameTo(fileFactory.folderFrom(newFolder)) ?: false + }.getOrNull() + } ?: false + + suspend fun renameFile( + folder: Folder, + oldFilename: Filename, + newFilename: Filename + ) = fileOperationIn(folder) { + runCatching { + fileFactory.fileFromWhenExists(folder, oldFilename) + ?.renameTo(fileFactory.fileFrom(folder, newFilename)) + ?: false + }.getOrNull() + } ?: false + + suspend fun writeToFile( + folder: Folder, + filename: Filename, + content: String + ): Boolean = writeToFile(folder, filename, content.toByteArray()) != null + + suspend fun writeToFile( + folder: Folder, + filename: Filename, + content: ByteArray + ): File? { + return fileOperationIn(folder) { + val fileToSave = fileFactory.fileFrom(folder, filename) + val result = runCatching { fileStreamFactory.outputStreamFrom(fileToSave).use { it.write(content) } } + when (result.isSuccess) { + true -> fileToSave + false -> null + } + } + } + + suspend fun writeToFileAsStream( + folder: Folder, + filename: Filename, + inputStream: InputStream + ): File? { + return fileOperationIn(folder) { + val fileToSave = fileFactory.fileFrom(folder, filename) + val result = runCatching { + inputStream.use { input -> + fileStreamFactory.outputStreamFrom(fileToSave).use { output -> + input.copyTo(output) + } + } + } + when (result.isSuccess) { + true -> fileToSave + false -> null + } + } + } + + suspend fun copyFile( + sourceFolder: Folder, + sourceFilename: Filename, + targetFolder: Folder, + targetFilename: Filename + ): File? = fileOperationIn(sourceFolder, targetFolder) { + runCatching { + fileFactory.fileFrom(sourceFolder, sourceFilename) + .copyTo(fileFactory.fileFrom(targetFolder, targetFilename)) + }.getOrNull() + } + + suspend fun deleteFile(folder: Folder, filename: Filename): Boolean = fileOperationIn(folder) { + runCatching { + fileFactory.fileFrom(folder, filename).delete() + }.getOrNull() + } ?: false + + suspend fun deleteFolder(folder: Folder): Boolean = fileOperationIn(folder) { + runCatching { + fileFactory.folderFrom(folder).deleteRecursively() + }.getOrNull() + } ?: false + + private suspend fun fileOperationIn(vararg folders: Folder, operation: () -> T): T? = + withContext(dispatcherProvider.Io) { + if (folders.any { it.isBlacklisted() }) { + Timber.w("Trying to access a blacklisted file directory: ${folders.map { it.path }}") + null + } else { + operation() + } + } + + private fun Folder.isBlacklisted(): Boolean { + val internalAppFiles = applicationContext.filesDir + val blacklistedFolders = listOf( + "${internalAppFiles.parent}/databases", + "${internalAppFiles.parent}/shared_prefs", + "${internalAppFiles.path}/datastore" + ) + return blacklistedFolders.any { blacklistedFolder -> path.normalised() == blacklistedFolder.normalised() } + } + + private fun String.normalised() = File(this).normalize().path + + data class Folder(val path: String) + + data class Filename(val value: String) + + class FileStreamFactory @Inject constructor() { + + fun inputStreamFrom(file: File): InputStream = FileInputStream(file) + + fun outputStreamFrom(file: File): OutputStream = FileOutputStream(file) + } + + class FileFactory @Inject constructor() { + + fun fileFrom(folder: Folder, filename: Filename) = File( + folderFrom(folder), + filename.value + ) + + fun fileFromWhenExists(folder: Folder, filename: Filename) = folderFromWhenExists(folder)?.let { dir -> + File(dir, filename.value).takeIf { it.exists() } + } + + fun folderFrom(folder: Folder) = File(folder.path).apply { mkdirs() } + + fun folderFromWhenExists(folder: Folder) = File(folder.path).takeIf { it.exists() } + } +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt new file mode 100644 index 0000000000..9b7f5bd485 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/IntentShareExtensions.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.net.MailTo +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import me.proton.core.util.kotlin.takeIfNotEmpty +import timber.log.Timber + +fun Intent.getShareInfo(): IntentShareInfo { + + return when (action) { + Intent.ACTION_SEND -> getShareInfoForSingleSendAction() + Intent.ACTION_SEND_MULTIPLE -> getShareInfoForMultipleSendAction() + Intent.ACTION_VIEW -> getShareInfoForViewAction() + Intent.ACTION_SENDTO -> getShareInfoForSendToAction() + + else -> { + Timber.d("Unhandled intent action: $action") + IntentShareInfo.Empty + } + } +} + +fun Intent.isStartedFromLauncher(): Boolean = action == Intent.ACTION_MAIN + +private fun Intent.getShareInfoForSingleSendAction(): IntentShareInfo { + val fileUriList = getFileUriForActionSend()?.let { + listOf(it.toString()) + } ?: emptyList() + + return IntentShareInfo( + attachmentUris = fileUriList, + emailSubject = getSubject(), + emailRecipientTo = getRecipientTo(), + emailRecipientCc = getRecipientCc(), + emailRecipientBcc = getRecipientBcc(), + emailBody = getEmailBody() + ) +} + +private fun Intent.getShareInfoForMultipleSendAction(): IntentShareInfo { + return IntentShareInfo( + attachmentUris = getFileUrisForActionSendMultiple().map { it.toString() }, + emailSubject = getSubject(), + emailRecipientTo = getRecipientTo(), + emailRecipientCc = getRecipientCc(), + emailRecipientBcc = getRecipientBcc(), + emailBody = getEmailBody() + ) +} + +private fun Intent.getShareInfoForViewAction(): IntentShareInfo { + val intentUri = getFileUrisForActionViewAndSendTo().takeIfNotEmpty()?.firstOrNull() + + return if (intentUri?.scheme == MAILTO_SCHEME) { + val mailTo = MailTo.parse(intentUri) + + val toRecipients = mailTo.to + ?.split(",") + ?.map { it.trim() } + ?: getRecipientTo() + + val ccRecipients: List = mailTo.cc + ?.split(",") + ?: getRecipientCc() + + val bccRecipients: List = mailTo.bcc + ?.split(",") + ?: getRecipientBcc() + + val subject = mailTo.subject ?: getSubject() + val body = mailTo.body ?: getEmailBody() + + IntentShareInfo( + attachmentUris = emptyList(), + emailSubject = subject, + emailRecipientTo = toRecipients, + emailRecipientCc = ccRecipients, + emailRecipientBcc = bccRecipients, + emailBody = body + ) + } else { + getShareInfoForSendToAction() + } +} + +private fun Intent.getShareInfoForSendToAction(): IntentShareInfo { + return IntentShareInfo( + attachmentUris = getFileUrisForActionViewAndSendTo().map { it.toString() }, + emailSubject = getSubject(), + emailRecipientTo = getRecipientTo(), + emailRecipientCc = getRecipientCc(), + emailRecipientBcc = getRecipientBcc(), + emailBody = getEmailBody() + ) +} + +private fun Intent.getFileUrisForActionViewAndSendTo(): List { + val fileUris = mutableListOf() + + data?.let { data -> + fileUris.add(data) + } + + return fileUris +} + +private fun Intent.getFileUriForActionSend(): Uri? { + val clipData = clipData + return if (clipData != null) { + clipData.getItemAt(0)?.uri + } else { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + getParcelableExtra(Intent.EXTRA_STREAM) + } else { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } + } +} + +private fun Intent.getFileUrisForActionSendMultiple(): List { + val fileUris = mutableListOf() + + val clipData = clipData + if (clipData != null) { + for (i in 0 until clipData.itemCount) { + clipData.getItemAt(i)?.uri?.run { fileUris.add(this) } + } + } else { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { fileUris.addAll(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let { fileUris.addAll(it) } + } + } + + return fileUris +} + +private fun Intent.getSubject(): String? = getStringExtra(Intent.EXTRA_SUBJECT) + +private fun Intent.getRecipientTo(): List = getStringArrayExtra(Intent.EXTRA_EMAIL)?.toList() ?: emptyList() + +private fun Intent.getRecipientCc(): List = getStringArrayExtra(Intent.EXTRA_CC)?.toList() ?: emptyList() + +private fun Intent.getRecipientBcc(): List = getStringArrayExtra(Intent.EXTRA_BCC)?.toList() ?: emptyList() + +private fun Intent.getEmailBody(): String? = getStringExtra(Intent.EXTRA_TEXT) + +/** + * Intent data can be a [Uri] with a mailto scheme instead of a shared file. + */ +private const val MAILTO_SCHEME = "mailto" diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt new file mode 100644 index 0000000000..68f8c0f208 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorage.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.File +import java.io.InputStream +import android.content.Context +import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import me.proton.core.domain.entity.UserId +import me.proton.core.util.kotlin.HashUtils +import javax.inject.Inject + +class InternalFileStorage @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val fileHelper: FileHelper, + @IODispatcher private val ioDispatcher: CoroutineDispatcher +) { + + suspend fun readFromFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): String? = fileHelper.readFromFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun readFromCachedFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): String? = fileHelper.readFromFile( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun renameFolder( + userId: UserId, + oldFolder: Folder, + newFolder: Folder + ) = fileHelper.renameFolder( + oldFolder = FileHelper.Folder("${userId.asRootDirectory()}${oldFolder.path}"), + newFolder = FileHelper.Folder("${userId.asRootDirectory()}${newFolder.path}") + ) + + suspend fun renameFile( + userId: UserId, + folder: Folder, + oldFileIdentifier: FileIdentifier, + newFileIdentifier: FileIdentifier + ) = fileHelper.renameFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + oldFilename = FileHelper.Filename(oldFileIdentifier.value.asSanitisedPath()), + newFilename = FileHelper.Filename(newFileIdentifier.value.asSanitisedPath()) + ) + + suspend fun getFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): File? = fileHelper.getFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun getCachedFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): File? = fileHelper.getFile( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun getFolder(userId: UserId, folder: Folder): File? = + fileHelper.getFolder(FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}")) + + suspend fun writeToFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier, + content: String + ): Boolean = fileHelper.writeToFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()), + content = content + ) + + suspend fun writeToCachedFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier, + content: String + ): Boolean = fileHelper.writeToFile( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()), + content = content + ) + + + suspend fun writeFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier, + content: ByteArray + ): File? = fileHelper.writeToFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()), + content = content + ) + + suspend fun writeCachedFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier, + content: ByteArray + ): File? = fileHelper.writeToFile( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()), + content = content + ) + + suspend fun writeFileAsStream( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier, + inputStream: InputStream + ): File? = fileHelper.writeToFileAsStream( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()), + inputStream = inputStream + ) + + suspend fun deleteFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): Boolean = fileHelper.deleteFile( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun deleteCachedFile( + userId: UserId, + folder: Folder, + fileIdentifier: FileIdentifier + ): Boolean = fileHelper.deleteFile( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}"), + filename = FileHelper.Filename(fileIdentifier.value.asSanitisedPath()) + ) + + suspend fun deleteFolder(userId: UserId, folder: Folder): Boolean = fileHelper.deleteFolder( + folder = FileHelper.Folder("${userId.asRootDirectory()}${folder.path}") + ) + + suspend fun deleteCachedFolder(userId: UserId, folder: Folder): Boolean = fileHelper.deleteFolder( + folder = FileHelper.Folder("${userId.asRootCacheDirectory()}${folder.path}") + ) + + suspend fun copyCachedFileToNonCachedFolder( + userId: UserId, + sourceFolder: Folder, + sourceFileIdentifier: FileIdentifier, + targetFolder: Folder, + targetFileIdentifier: FileIdentifier + ): File? = fileHelper.copyFile( + sourceFolder = FileHelper.Folder("${userId.asRootCacheDirectory()}${sourceFolder.path}"), + sourceFilename = FileHelper.Filename(sourceFileIdentifier.value.asSanitisedPath()), + targetFolder = FileHelper.Folder("${userId.asRootDirectory()}${targetFolder.path}"), + targetFilename = FileHelper.Filename(targetFileIdentifier.value.asSanitisedPath()) + ) + + private suspend fun UserId.asRootDirectory() = withContext(ioDispatcher) { + "${applicationContext.filesDir}/${id.asSanitisedPath()}/" + } + + private suspend fun UserId.asRootCacheDirectory() = withContext(ioDispatcher) { + "${applicationContext.cacheDir}/${id.asSanitisedPath()}/" + } + + private fun String.asSanitisedPath() = HashUtils.sha256(this) + + @JvmInline + value class FileIdentifier(val value: String) + + sealed class Folder(open val path: String) { + object MessageBodies : Folder("message_bodies/") + object MessageAttachmentsRoot : Folder("attachments/") + data class MessageAttachments(val messageId: String) : Folder("${MessageAttachmentsRoot.path}$messageId/") + } +} + +/** + * See https://www.sqlite.org/intern-v-extern-blob.html + * + * The article compares the performance of storing large blobs in db directly vs storing them as files on disk. + * It does not directly relate to Android, and no actual testing was done on Android from our side. + * The assumption is that the performance findings will roughly translate to Android nevertheless and the cut-off + * threshold of 500kB is chosen based on the measurements from the article. + */ +@Suppress("MagicNumber") +fun String.shouldBeStoredAsFile() = this.toByteArray().size > 500 * 1024 diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt new file mode 100644 index 0000000000..508d295c4b --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelper.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.InputStream +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import me.proton.core.util.kotlin.DispatcherProvider +import javax.inject.Inject + +class UriHelper @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val contentResolverHelper: ContentResolverHelper +) { + + suspend fun readFromUri(uri: Uri): InputStream? = withContext(dispatcherProvider.Io) { + runCatching { contentResolverHelper.openInputStream(uri) }.getOrNull() + } + + suspend fun getFileInformationFromUri(uri: Uri): FileInformation? { + val name = getFileNameFromUri(uri) + val size = getFileSizeFromUri(uri) + val mimeType = getFileMimeTypeFromUri(uri) + + if (name.isNullOrEmpty() || size == null || mimeType.isNullOrEmpty()) return null + + return FileInformation(name, size, mimeType) + } + + private suspend fun getFileNameFromUri(uri: Uri) = withContext(dispatcherProvider.Io) { + contentResolverHelper.query(uri) + ?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + } + } + + private suspend fun getFileSizeFromUri(uri: Uri) = withContext(dispatcherProvider.Io) { + contentResolverHelper.query(uri) + ?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } + } + + private suspend fun getFileMimeTypeFromUri(uri: Uri) = withContext(dispatcherProvider.Io) { + contentResolverHelper.getType(uri) + } +} + +class ContentResolverHelper @Inject constructor( + @ApplicationContext private val applicationContext: Context +) { + + fun openInputStream(uri: Uri) = applicationContext.contentResolver.openInputStream(uri) + + fun getType(uri: Uri) = applicationContext.contentResolver.getType(uri) + + fun query(uri: Uri) = applicationContext.contentResolver.query(uri, null, null, null, null) +} + +data class FileInformation( + val name: String, + val size: Long, + val mimeType: String +) diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt new file mode 100644 index 0000000000..a50dea1afc --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMapping.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.mapper + +import java.net.UnknownHostException +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.mapper.fromHttpCode +import ch.protonmail.android.mailcommon.domain.mapper.fromProtonCode +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import me.proton.core.network.domain.ApiResult +import me.proton.core.network.domain.isRetryable +import timber.log.Timber + +fun ApiResult.toEither(): Either = when (this) { + is ApiResult.Success -> value.right() + + is ApiResult.Error.Http -> { + when { + isMessageAlreadySentDraftError() -> DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft).left() + isMessageAlreadySentAttachmentError() -> + DataError.Remote.Proton(ProtonError.AttachmentUploadMessageAlreadySent).left() + isMessageAlreadySentSendingError() -> DataError.Remote.Proton(ProtonError.MessageAlreadySent).left() + isSearchInputInvalidError() -> DataError.Remote.Proton(ProtonError.SearchInputInvalid).left() + else -> DataError.Remote.Http( + NetworkError.fromHttpCode(httpCode), + this.extractApiErrorInfo(), + this.isRetryable() + ).left() + } + } + + is ApiResult.Error.Parse -> { + Timber.e("Unexpected parse error, caused by: ${this.cause}") + DataError.Remote.Http(NetworkError.Parse, this.cause.tryExtractError(), this.isRetryable()).left() + } + + is ApiResult.Error.Connection -> { + DataError.Remote.Http(toNetworkError(this), this.cause.tryExtractError(), this.isRetryable()).left() + } +} + +private fun ApiResult.Error.Http.isMessageAlreadySentDraftError() = + NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity && + ProtonError.fromProtonCode(this.proton?.code) == ProtonError.MessageUpdateDraftNotDraft + +private fun ApiResult.Error.Http.isMessageAlreadySentAttachmentError() = + NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity && + ProtonError.fromProtonCode(this.proton?.code) == ProtonError.AttachmentUploadMessageAlreadySent + +private fun ApiResult.Error.Http.isMessageAlreadySentSendingError() = + NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity && + ProtonError.fromProtonCode(this.proton?.code) == ProtonError.MessageAlreadySent + +private fun ApiResult.Error.Http.isSearchInputInvalidError() = + NetworkError.fromHttpCode(this.httpCode) == NetworkError.UnprocessableEntity && + ProtonError.fromProtonCode(this.proton?.code) == ProtonError.SearchInputInvalid + +private fun Throwable?.tryExtractError() = this?.cause?.message ?: "No error message found" + +private fun ApiResult.Error.Http.extractApiErrorInfo(): String = "${this.message} - ${this.proton?.error}" + +private fun toNetworkError(apiResult: ApiResult.Error.Connection): NetworkError = when (apiResult) { + is ApiResult.Error.NoInternet -> NetworkError.NoNetwork + else -> if (apiResult.cause is UnknownHostException) { + NetworkError.NoNetwork + } else { + NetworkError.Unreachable + } +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt new file mode 100644 index 0000000000..803ea8c561 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappings.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.mapper + +import java.io.IOException +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.PreferencesError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import timber.log.Timber + +val DataStore.safeData: Flow> + // type inference fails to resolve type for `Preferences.right() as Either` + @Suppress("USELESS_CAST") + get() = data.map { it.right() as Either } + .catch { throwable -> + if (throwable is IOException) { + Timber.e(throwable, "Error reading preference") + emit(PreferencesError.left()) + } else throw throwable + } + +suspend fun DataStore.safeEdit( + transform: suspend (MutablePreferences) -> Unit +): Either = try { + edit(transform).right() +} catch (exception: IOException) { + Timber.e(exception, "Error saving preference") + PreferencesError.left() +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt new file mode 100644 index 0000000000..496efcb138 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImpl.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.repository + +import java.util.Locale +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.appcompat.app.AppCompatDelegate +import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository + +class AppLocaleRepositoryImpl(val context: Context) : AppLocaleRepository, BroadcastReceiver() { + + private var savedLocale: Locale? = null + + init { + val localeBroadcastFilter = IntentFilter().apply { + addAction(Intent.ACTION_LOCALE_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + } + + context.registerReceiver(this, localeBroadcastFilter) + } + + override fun current(): Locale { + // AppCompatDelegate.getApplicationLocales makes IPC call to Locale Service on Android 13 and above. + // So it's better to cache the result + if (savedLocale == null) { + savedLocale = obtainCurrentLocale() + } + + return savedLocale ?: Locale.getDefault() // Use default Locale if savedLocale is null + } + + override fun refresh() { + savedLocale = obtainCurrentLocale() + } + + private fun obtainCurrentLocale(): Locale { + val savedAppLocales = AppCompatDelegate.getApplicationLocales() + val languageTag = savedAppLocales[0]?.toLanguageTag() ?: Locale.getDefault().toLanguageTag() + return Locale.forLanguageTag(languageTag) + } + + // Refresh saved locale when app locale changes + override fun onReceive(context: Context?, intent: Intent?) { + refresh() + } +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt new file mode 100644 index 0000000000..9d22314525 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepository.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.repository + +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UndoableOperationInMemoryRepository @Inject constructor() : UndoableOperationRepository { + + private var operation: UndoableOperation? = null + + override suspend fun storeOperation(operation: UndoableOperation) { + this.operation = operation + } + + override suspend fun getLastOperation(): UndoableOperation? = operation + +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt new file mode 100644 index 0000000000..f954d4d4ac --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AccountEntitySample.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.AccountSample +import me.proton.core.account.data.entity.AccountEntity +import me.proton.core.account.domain.entity.Account + +object AccountEntitySample { + + val Primary = build(AccountSample.Primary) + + val PrimaryNotReady = build(AccountSample.PrimaryNotReady) + + fun build(account: Account = AccountSample.build()) = AccountEntity( + email = account.email, + sessionId = account.sessionId, + sessionState = account.sessionState, + state = account.state, + userId = account.userId, + username = account.username + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt new file mode 100644 index 0000000000..716f754c90 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/AddressEntitySample.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.AddressIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import me.proton.core.user.data.entity.AddressEntity + +object AddressEntitySample { + + val Primary = AddressEntity( + addressId = AddressIdSample.Primary, + canReceive = true, + canSend = true, + email = "primary@proton.me", + enabled = true, + order = 1, + signedKeyList = null, + type = 1, + userId = UserIdSample.Primary + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt new file mode 100644 index 0000000000..295fb74210 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/LabelEntitySample.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.LabelSample +import me.proton.core.label.data.local.LabelEntity +import me.proton.core.label.domain.entity.Label + +object LabelEntitySample { + + val Archive = build(LabelSample.Archive) + val Document = build(LabelSample.Document) + + fun build(label: Label = LabelSample.build()) = LabelEntity( + color = label.color, + isExpanded = label.isExpanded, + isNotified = label.isNotified, + isSticky = label.isSticky, + labelId = label.labelId, + name = label.name, + order = label.order, + parentId = label.parentId?.id, + path = label.path, + type = label.type.value, + userId = label.userId + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt new file mode 100644 index 0000000000..c3b4270629 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/SessionEntitySample.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.SessionSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import me.proton.core.account.data.entity.SessionEntity +import me.proton.core.domain.entity.Product +import me.proton.core.network.domain.session.Session + +object SessionEntitySample { + + val Primary = build(SessionSample.Primary) + + fun build(session: Session = SessionSample.build()) = SessionEntity( + accessToken = session.accessToken, + product = Product.Mail, + refreshToken = session.refreshToken, + scopes = session.scopes, + sessionId = session.sessionId, + userId = UserIdSample.Primary + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt new file mode 100644 index 0000000000..9f57396649 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/sample/UserEntitySample.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.UserSample +import me.proton.core.user.data.entity.UserEntity +import me.proton.core.user.domain.entity.User + +object UserEntitySample { + + val Primary = build(UserSample.Primary) + + fun build(user: User = UserSample.build()) = UserEntity( + type = 0, + credit = user.credit, + createdAtUtc = user.createdAtUtc, + currency = user.currency, + delinquent = user.delinquent?.value, + displayName = user.displayName, + email = user.email, + isPrivate = user.private, + maxSpace = user.maxSpace, + maxUpload = user.maxUpload, + name = user.name, + passphrase = null, + role = user.role?.value, + services = user.services, + subscribed = user.subscribed, + usedSpace = user.usedSpace, + userId = user.userId, + recovery = null, + maxBaseSpace = null, + maxDriveSpace = null, + usedBaseSpace = null, + usedDriveSpace = null, + flags = null + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt new file mode 100644 index 0000000000..2ac52cf89b --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/BuildVersionProviderImpl.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.system + +import android.os.Build +import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider +import javax.inject.Inject + +class BuildVersionProviderImpl @Inject constructor() : BuildVersionProvider { + + override fun sdkInt() = Build.VERSION.SDK_INT +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt new file mode 100644 index 0000000000..2b39c3d6f3 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/ContentValuesProviderImpl.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.system + +import android.content.ContentValues +import ch.protonmail.android.mailcommon.domain.system.ContentValuesProvider +import javax.inject.Inject + +class ContentValuesProviderImpl @Inject constructor() : ContentValuesProvider { + + override fun provideContentValues() = ContentValues() +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt new file mode 100644 index 0000000000..424d6deac6 --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImpl.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.system + +import android.webkit.WebView +import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities +import javax.inject.Inject + +class DeviceCapabilitiesImpl @Inject constructor() : DeviceCapabilities { + + // No need for custom getters as per official documentation: + // "If the WebView package changes, any app process that has loaded WebView will be killed." + // See `WebView.getCurrentWebViewPackage()` docs for more info. + override fun getCapabilities(): DeviceCapabilities.Capabilities = DeviceCapabilities.Capabilities( + hasWebView = WebView.getCurrentWebViewPackage()?.applicationInfo?.enabled ?: false + ) +} diff --git a/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt new file mode 100644 index 0000000000..f7d4b42d9b --- /dev/null +++ b/mail-common/data/src/main/kotlin/ch/protonmail/android/mailcommon/data/worker/Enqueuer.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.worker + +import java.util.concurrent.TimeUnit +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class Enqueuer @Inject constructor(private val workManager: WorkManager) { + + inline fun enqueue(userId: UserId, params: Map) { + enqueue(userId, T::class.java, params) + } + + inline fun enqueueUniqueWork( + userId: UserId, + workerId: String, + params: Map, + constraints: Constraints? = buildDefaultConstraints(), + existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP + ) { + enqueueUniqueWork(userId, workerId, T::class.java, params, constraints, existingWorkPolicy) + } + + inline fun enqueueInChain( + userId: UserId, + uniqueWorkId: String, + params1: Map, + params2: Map, + constraints: Constraints = buildDefaultConstraints(), + existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP + ) { + enqueueInChain( + userId, + uniqueWorkId, + T::class.java, + params1, + K::class.java, + params2, + constraints, + existingWorkPolicy + ) + } + + inline fun < + reified T : ListenableWorker, + reified K : ListenableWorker, + reified R : ListenableWorker + > enqueueInChain( + userId: UserId, + uniqueWorkId: String, + params1: Map, + params2: Map, + params3: Map, + constraints: Constraints = buildDefaultConstraints(), + existingWorkPolicy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP + ) { + enqueueInChain( + userId, + uniqueWorkId, + T::class.java, + params1, + K::class.java, + params2, + R::class.java, + params3, + constraints, + existingWorkPolicy + ) + } + + fun enqueue( + userId: UserId, + worker: Class, + params: Map + ) { + workManager.enqueue(createRequest(userId, worker, null, params, buildDefaultConstraints())) + } + + @Suppress("LongParameterList") + fun enqueueInChain( + userId: UserId, + uniqueWorkId: String, + worker1: Class, + params1: Map, + worker2: Class, + params2: Map, + constraints: Constraints, + existingWorkPolicy: ExistingWorkPolicy + ) { + workManager.beginUniqueWork( + uniqueWorkId, + existingWorkPolicy, + createRequest(userId, worker1, uniqueWorkId, params1, constraints) + ) + .then(createRequest(userId, worker2, uniqueWorkId, params2, constraints)) + .enqueue() + } + + @Suppress("LongParameterList") + fun enqueueInChain( + userId: UserId, + uniqueWorkId: String, + worker1: Class, + params1: Map, + worker2: Class, + params2: Map, + worker3: Class, + params3: Map, + constraints: Constraints, + existingWorkPolicy: ExistingWorkPolicy + ) { + workManager.beginUniqueWork( + uniqueWorkId, + existingWorkPolicy, + createRequest(userId, worker1, uniqueWorkId, params1, constraints) + ) + .then(createRequest(userId, worker2, uniqueWorkId, params2, constraints)) + .then(createRequest(userId, worker3, uniqueWorkId, params3, constraints)) + .enqueue() + } + + fun cancelAllWork(userId: UserId) { + workManager.cancelAllWorkByTag(userId.id) + } + + fun observeWorkStatusIsEnqueuedOrRunning(workerId: String): Flow = + workManager.getWorkInfosForUniqueWorkFlow(workerId) + .map { workInfos -> + workInfos + .firstOrNull() + ?.let { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING } + ?: false + } + + @Suppress("LongParameterList") + fun enqueueUniqueWork( + userId: UserId, + workerId: String, + worker: Class, + params: Map, + constraints: Constraints?, + existingWorkPolicy: ExistingWorkPolicy + ) { + workManager.enqueueUniqueWork( + workerId, + existingWorkPolicy, + createRequest(userId, worker, workerId, params, constraints) + ) + } + + private fun createRequest( + userId: UserId, + worker: Class, + workId: String? = null, + params: Map, + constraints: Constraints? + ): OneTimeWorkRequest { + + val data = workDataOf(*params.map { Pair(it.key, it.value) }.toTypedArray()) + + return OneTimeWorkRequest.Builder(worker).run { + setInputData(data) + addTag(userId.id) + if (workId != null) addTag(workId) + if (constraints != null) setConstraints(constraints) + setBackoffCriteria( + backoffPolicy = BackoffPolicy.LINEAR, + backoffDelay = 20, + timeUnit = TimeUnit.SECONDS + ) + build() + } + } + + fun buildDefaultConstraints(): Constraints { + return Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/db/BaseDaoExtensionsKtTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/db/BaseDaoExtensionsKtTest.kt new file mode 100644 index 0000000000..52223edebe --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/db/BaseDaoExtensionsKtTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.db + +import android.database.sqlite.SQLiteConstraintException +import ch.protonmail.android.mailcommon.data.db.dao.upsertOrError +import ch.protonmail.android.mailcommon.domain.model.DaoError +import io.mockk.coEvery +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import me.proton.core.data.room.db.BaseDao +import org.junit.Test +import kotlin.test.assertIs + +internal class BaseDaoExtensionsKtTest { + + private val dao = spyk>() + + @Test + fun `when the underlying calls throw, should wrap the throwable into an error`() = runTest { + coEvery { dao.insertOrUpdate(1) } throws SQLiteConstraintException() + + // When + val error = dao.upsertOrError(entities = arrayOf(1)).leftOrNull() + + // Then + assertIs(error) + assertIs(error.throwable) + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelperTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelperTest.kt new file mode 100644 index 0000000000..ae4e9daa30 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/FileHelperTest.kt @@ -0,0 +1,603 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import android.content.Context +import ch.protonmail.android.mailcommon.data.file.TestData.FileContent +import ch.protonmail.android.mailcommon.data.file.TestData.InternalStoragePath +import ch.protonmail.android.mailcommon.data.file.TestData.failingInputStream +import ch.protonmail.android.mailcommon.data.file.TestData.failingOutputStream +import ch.protonmail.android.mailcommon.data.file.TestData.filename +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import me.proton.core.test.kotlin.TestDispatcherProvider +import me.proton.core.util.kotlin.EMPTY_STRING +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertNull + +internal open class FileHelperTest(folderPath: String) { + + private val contextMock = mockk { + every { filesDir } returns File(InternalStoragePath) + } + protected val folder: FileHelper.Folder = FileHelper.Folder(folderPath) + protected val fileStreamFactoryMock = mockk() + protected val fileFactoryMock = mockk() + protected val testDispatcherProvider = TestDispatcherProvider() + protected val fileHelper = FileHelper(fileStreamFactoryMock, fileFactoryMock, testDispatcherProvider, contextMock) +} + +@RunWith(Parameterized::class) +internal class AllowedFoldersFileHelperTest(folderPath: String) : FileHelperTest(folderPath) { + + @Test + fun `should read contents of a file`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val inputStream = ByteArrayInputStream(FileContent.toByteArray()) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.inputStreamFrom(file) } returns inputStream + + // When + val fileContent = fileHelper.readFromFile(folder, filename) + + // Then + assertEquals(FileContent, fileContent) + } + + @Test + @Suppress("BlockingMethodInNonBlockingContext") + fun `should read a file`() = runTest(testDispatcherProvider.Main) { + // Given + val expectedFile = File.createTempFile("test", "test") + every { fileFactoryMock.fileFrom(folder, filename) } returns expectedFile + + // When + val actual = fileHelper.getFile(folder, filename) + + // Then + assertEquals(expectedFile, actual) + } + + @Test + fun `should return null when file doesn't exist`() = runTest(testDispatcherProvider.Main) { + // Given + val nonExistentFile = File(folder.path, filename.value) + check(nonExistentFile.exists().not()) + every { fileFactoryMock.fileFrom(folder, filename) } returns nonExistentFile + + // When + val result = fileHelper.getFile(folder, filename) + + // Then + assertNull(result) + } + + @Test + fun `should return null when reading a file failed`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.inputStreamFrom(file) } returns failingInputStream + + // When + val result = fileHelper.readFromFile(folder, filename) + + // Then + assertNull(result) + } + + @Test + fun `should return null when writing to a file failed`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.inputStreamFrom(file) } returns failingInputStream + + // When + val fileContent = fileHelper.readFromFile(folder, filename) + + // Then + assertNull(fileContent) + } + + @Test + fun `should write contents to a file`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val outputStream = ByteArrayOutputStream() + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns outputStream + + // When + val fileSaved = fileHelper.writeToFile(folder, filename, FileContent) + + // Then + val actualContent = String(outputStream.toByteArray()) + assertEquals(FileContent, actualContent) + assertTrue(fileSaved) + } + + @Test + fun `should return false when writing to a file failed`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns failingOutputStream + + // When + val fileSaved = fileHelper.writeToFile(folder, filename, FileContent) + + // Then + assertFalse(fileSaved) + } + + @Test + fun `should return file when writing to a file as stream`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val outputStream = ByteArrayOutputStream() + val inputStream = ByteArrayInputStream(FileContent.toByteArray()) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns outputStream + + // When + val fileSaved = fileHelper.writeToFileAsStream(folder, filename, inputStream) + + // Then + assertTrue(fileSaved != null) + } + + @Test + fun `should return null when writing to a file as stream fails`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val inputStream = ByteArrayInputStream(FileContent.toByteArray()) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns failingOutputStream + + // When + val fileSaved = fileHelper.writeToFileAsStream(folder, filename, inputStream) + + // Then + assertNull(fileSaved) + } + + @Test + fun `should delete a file`() = runTest(testDispatcherProvider.Main) { + // Given + val fileMock = mockk { every { delete() } returns true } + every { fileFactoryMock.fileFrom(folder, filename) } returns fileMock + + // When + val fileDeleted = fileHelper.deleteFile(folder, filename) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should return false when deleting file fails`() = runTest(testDispatcherProvider.Main) { + // Given + val fileMock = mockk { every { delete() } returns false } + every { fileFactoryMock.fileFrom(folder, filename) } returns fileMock + + // When + val fileDeleted = fileHelper.deleteFile(folder, filename) + + // Then + assertFalse(fileDeleted) + } + + @Test + fun `should delete a folder`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::deleteRecursively) + val fileMock = mockk { every { deleteRecursively() } returns true } + every { fileFactoryMock.folderFrom(folder) } returns fileMock + + // When + val folderDeleted = fileHelper.deleteFolder(folder) + + // Then + assertTrue(folderDeleted) + unmockkStatic(File::deleteRecursively) + } + + @Test + fun `should return false when deleting folder fails`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::deleteRecursively) + val fileMock = mockk { every { deleteRecursively() } returns false } + every { fileFactoryMock.folderFrom(folder) } returns fileMock + + // When + val folderDeleted = fileHelper.deleteFolder(folder) + + // Then + assertFalse(folderDeleted) + unmockkStatic(File::deleteRecursively) + } + + @Test + fun `should rename a folder`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFolder = FileHelper.Folder(folder.path + "_new") + val newFileMock = mockk() + val oldFileMock = mockk { + every { renameTo(newFileMock) } returns true + } + every { fileFactoryMock.folderFromWhenExists(folder) } returns oldFileMock + every { fileFactoryMock.folderFrom(newFolder) } returns newFileMock + + + // When + val folderRenamed = fileHelper.renameFolder(folder, newFolder) + + // Then + assertTrue(folderRenamed) + unmockkStatic(File::renameTo) + } + + @Test + fun `should return false when renaming a folder fails`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFolder = FileHelper.Folder(folder.path + "_new") + val newFileMock = mockk() + val oldFileMock = mockk { every { renameTo(newFileMock) } returns false } + every { fileFactoryMock.folderFrom(folder) } returns oldFileMock + every { fileFactoryMock.folderFrom(newFolder) } returns newFileMock + + + // When + val folderRenamed = fileHelper.renameFolder(folder, newFolder) + + // Then + assertFalse(folderRenamed) + unmockkStatic(File::renameTo) + } + + @Test + fun `should rename a file`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFilename = FileHelper.Filename(filename.value + "_new") + val newFileMock = mockk() + val oldFileMock = mockk { every { renameTo(newFileMock) } returns true } + every { fileFactoryMock.fileFromWhenExists(folder, filename) } returns oldFileMock + every { fileFactoryMock.fileFrom(folder, newFilename) } returns newFileMock + + // When + val fileRenamed = fileHelper.renameFile(folder, filename, newFilename) + + // Then + assertTrue(fileRenamed) + unmockkStatic(File::renameTo) + } + + @Test + fun `should return false when renaming a file fails`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFilename = FileHelper.Filename(filename.value + "_new") + val newFileMock = mockk() + val oldFileMock = mockk { every { renameTo(newFileMock) } returns false } + every { fileFactoryMock.fileFromWhenExists(folder, filename) } returns oldFileMock + every { fileFactoryMock.fileFrom(folder, newFilename) } returns newFileMock + + // When + val fileRenamed = fileHelper.renameFile(folder, filename, newFilename) + + // Then + assertFalse(fileRenamed) + unmockkStatic(File::renameTo) + } + + @Test + fun `should return false when renaming a file fails because file doesn't exist`() = + runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFilename = FileHelper.Filename(filename.value + "_new") + val newFileMock = mockk() + every { fileFactoryMock.fileFromWhenExists(folder, filename) } returns null + every { fileFactoryMock.fileFrom(folder, newFilename) } returns newFileMock + + // When + val fileRenamed = fileHelper.renameFile(folder, filename, newFilename) + + // Then + assertFalse(fileRenamed) + unmockkStatic(File::renameTo) + } + + @Test + fun `should return file when copying it from a source folder to a target folder was successful`() = + runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::copyTo) + val sourceFolder = FileHelper.Folder(folder.path + "_source") + val sourceFilename = FileHelper.Filename(filename.value + "_source") + val targetFolder = FileHelper.Folder(folder.path + "_target") + val targetFilename = FileHelper.Filename(filename.value + "_target") + val sourceFileMock = mockk() + val targetFileMock = mockk() + every { sourceFileMock.copyTo(targetFileMock) } returns targetFileMock + every { fileFactoryMock.fileFrom(sourceFolder, sourceFilename) } returns sourceFileMock + every { fileFactoryMock.fileFrom(targetFolder, targetFilename) } returns targetFileMock + + // When + val copiedFile = fileHelper.copyFile(sourceFolder, sourceFilename, targetFolder, targetFilename) + + // Then + assertEquals(targetFileMock, copiedFile) + verify { sourceFileMock.copyTo(targetFileMock) } + mockkStatic(File::copyTo) + } + + @Test + fun `should return null when copying file from a source folder to a target folder has failed`() = + runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::copyTo) + val sourceFolder = FileHelper.Folder(folder.path + "_source") + val sourceFilename = FileHelper.Filename(filename.value + "_source") + val targetFolder = FileHelper.Folder(folder.path + "_target") + val targetFilename = FileHelper.Filename(filename.value + "_target") + val sourceFileMock = mockk() + val targetFileMock = mockk() + every { sourceFileMock.copyTo(targetFileMock) } throws IOException() + every { fileFactoryMock.fileFrom(sourceFolder, sourceFilename) } returns sourceFileMock + every { fileFactoryMock.fileFrom(targetFolder, targetFilename) } returns targetFileMock + + // When + val copiedFile = fileHelper.copyFile(sourceFolder, sourceFilename, targetFolder, targetFilename) + + // Then + assertNull(copiedFile) + verify { sourceFileMock.copyTo(targetFileMock) } + mockkStatic(File::copyTo) + } + + companion object { + + private val allowedFolders = listOf( + "/data/user/0/ch.protonmail.android.alpha/files/", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/message_bodies/", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/attachments/", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/images/", + "/storage/emulated/0", + "/storage/emulated/1", + "/storage/emulated/0/Download/", + "/storage/emulated/0/Pictures/", + "/storage/emulated/1/Movies/", + "/storage/emulated/1/DCIM/", + "/storage/emulated/1/../0/DCIM", + "/data/user/0/ch.protonmail.android.alpha/databases/../../../../../storage/emulated/0", + "/data/user/0/ch.protonmail.android.alpha/databases/../files/user1234/attachments" + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = allowedFolders.map { arrayOf(it) } + } +} + +@RunWith(Parameterized::class) +internal class BlacklistedFoldersFileHelperTest(folderPath: String) : FileHelperTest(folderPath) { + + @Test + fun `should return null when trying to read from a blacklisted folder`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val inputStream = ByteArrayInputStream(FileContent.toByteArray()) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.inputStreamFrom(file) } returns inputStream + + // When + val fileContent = fileHelper.readFromFile(folder, filename) + + // Then + assertNull(fileContent) + } + + @Test + fun `should return null when trying to get File from blacklisted folder`() = runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + + // When + val fileContent = fileHelper.getFile(folder, filename) + + // Then + assertNull(fileContent) + } + + @Test + fun `should return false and not write contents when trying to write to a blacklisted folder`() = + runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val outputStream = ByteArrayOutputStream() + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns outputStream + + // When + val fileSaved = fileHelper.writeToFile(folder, filename, FileContent) + + // Then + val actualContent = String(outputStream.toByteArray()) + assertEquals(EMPTY_STRING, actualContent) + assertFalse(fileSaved) + } + + @Test + fun `should return null when trying to write a file to a blacklisted folder as stream`() = + runTest(testDispatcherProvider.Main) { + // Given + val file = File(folder.path, filename.value) + val outputStream = ByteArrayOutputStream() + val inputStream = ByteArrayInputStream(FileContent.toByteArray()) + every { fileFactoryMock.fileFrom(folder, filename) } returns file + every { fileStreamFactoryMock.outputStreamFrom(file) } returns outputStream + + // When + val fileSaved = fileHelper.writeToFileAsStream(folder, filename, inputStream) + + // Then + assertNull(fileSaved) + } + + @Test + fun `should return false when trying to delete from a blacklisted folder`() = runTest(testDispatcherProvider.Main) { + // Given + val fileMock = mockk() + every { fileFactoryMock.fileFrom(folder, filename) } returns fileMock + + // When + val fileDeleted = fileHelper.deleteFile(folder, filename) + + // Then + verify(exactly = 0) { fileMock.delete() } + assertFalse(fileDeleted) + } + + @Test + fun `should return false when trying to delete a blacklisted folder`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::deleteRecursively) + val fileMock = mockk() + every { fileFactoryMock.folderFrom(folder) } returns fileMock + + // When + val folderDeleted = fileHelper.deleteFolder(folder) + + // Then + assertFalse(folderDeleted) + verify(exactly = 0) { fileMock.deleteRecursively() } + unmockkStatic(File::deleteRecursively) + } + + @Test + fun `should return false when trying to rename a blacklisted folder`() = runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFolder = FileHelper.Folder(folder.path + "_new") + val newFileMock = mockk() + val fileMock = mockk { every { renameTo(newFileMock) } returns true } + every { fileFactoryMock.folderFrom(folder) } returns fileMock + + // When + val folderRenamed = fileHelper.renameFolder(folder, newFolder) + + // Then + assertFalse(folderRenamed) + verify(exactly = 0) { fileMock.renameTo(newFileMock) } + unmockkStatic(File::renameTo) + } + + @Test + fun `should return false when trying to rename a file in a blacklisted folder`() = + runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::renameTo) + val newFilename = FileHelper.Filename(filename.value + "_new") + val newFileMock = mockk() + val fileMock = mockk { every { renameTo(newFileMock) } returns true } + + // When + val fileRenamed = fileHelper.renameFile(folder, filename, newFilename) + + // Then + assertFalse(fileRenamed) + verify(exactly = 0) { fileMock.renameTo(newFileMock) } + unmockkStatic(File::renameTo) + } + + @Test + fun `should return null when trying to copy a file from a restricted folder`() = + runTest(testDispatcherProvider.Main) { + // Given + mockkStatic(File::copyTo) + val sourceFolder = FileHelper.Folder(folder.path + "_source") + val sourceFilename = FileHelper.Filename(filename.value + "_source") + val targetFolder = FileHelper.Folder(folder.path + "_target") + val targetFilename = FileHelper.Filename(filename.value + "_target") + val sourceFileMock = mockk() + val targetFileMock = mockk() + + // When + val copiedFile = fileHelper.copyFile(sourceFolder, sourceFilename, targetFolder, targetFilename) + + // Then + assertNull(copiedFile) + verify(exactly = 0) { sourceFileMock.copyTo(targetFileMock) } + mockkStatic(File::copyTo) + } + + companion object { + + private val blacklistedFolders = listOf( + "/data/user/0/ch.protonmail.android.alpha/databases/", + "/data/user/0/ch.protonmail.android.alpha/shared_prefs/", + "/data/user/0/ch.protonmail.android.alpha/files/datastore/", + "/data/user/0/ch.protonmail.android.alpha/files/../shared_prefs", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/images/../../../databases/", + "/data/user/0/ch.protonmail.android.alpha/files/userid1234/message_bodies/../../../shared_prefs/", + "/storage/emulated/0/../../../data/user/0/ch.protonmail.android.alpha/databases/" + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = blacklistedFolders.map { arrayOf(it) } + } +} + +private object TestData { + + const val FileContent = "I am a file content" + const val InternalStoragePath = "/data/user/0/ch.protonmail.android.alpha/files" + + val filename = FileHelper.Filename("file_name") + val failingInputStream = object : InputStream() { + override fun read(): Int = throw IllegalStateException() + } + val failingOutputStream = object : OutputStream() { + override fun write(b: Int) = throw IllegalStateException() + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/IntentExtensionsTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/IntentExtensionsTest.kt new file mode 100644 index 0000000000..350051a2c5 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/IntentExtensionsTest.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import android.content.ClipData +import android.content.Intent +import android.net.Uri +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class IntentExtensionsTest { + + private val uri1 = mockk { + every { scheme } returns "content" + } + private val uri2 = mockk { + every { scheme } returns "content" + } + private val mailToUri = mockk { + every { scheme } returns "mailto" + } + + @Before + fun setUp() { + mockkStatic(Uri::class) + } + + @After + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun `should return empty share info when intent has unhandled action`() { + // Given + val intent = mockIntent(action = "unhandled_action") + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(IntentShareInfo.Empty, fileShareInfo) + } + + @Test + fun `should return empty share info when no Uri in intent data for action view`() { + // Given + val intent = mockIntent(action = Intent.ACTION_VIEW, data = null) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(IntentShareInfo.Empty, fileShareInfo) + } + + @Test + fun `should return file uri when uri defined intent data for action view`() { + // Given + every { uri1.toString() } returns "content://test1" + val intent = mockIntent(action = Intent.ACTION_VIEW, data = uri1) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return share info with recipient when uri has mailto scheme in intent data for action view`() { + // Given + val recipientEmail = "user1@example.com" + every { Uri.decode(any()) } returns recipientEmail + every { Uri.parse(any()) } returns mailToUri + every { mailToUri.toString() } returns "mailto:$recipientEmail" + val intent = mockIntent(action = Intent.ACTION_VIEW, data = mailToUri) + val expected = IntentShareInfo.Empty.copy(emailRecipientTo = listOf(recipientEmail)) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return share info with multiple recipients when uri has mailto scheme in intent for action view`() { + // Given + val recipientEmail1 = "user1@example.com" + val recipientEmail2 = "user2@example.com" + val recipientEmail3 = "user3@example.com" + every { Uri.decode(any()) } returns "$recipientEmail1,$recipientEmail2,$recipientEmail3" + every { Uri.parse(any()) } returns mailToUri + every { mailToUri.toString() } returns "mailto:$recipientEmail1,$recipientEmail2,$recipientEmail3" + val intent = mockIntent(action = Intent.ACTION_VIEW, data = mailToUri) + val expected = IntentShareInfo.Empty.copy( + emailRecipientTo = listOf( + recipientEmail1, + recipientEmail2, + recipientEmail3 + ) + ) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return empty share info when no Uri in intent data for action sendto`() { + // Given + val intent = mockIntent(action = Intent.ACTION_SENDTO, data = null) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(IntentShareInfo.Empty, fileShareInfo) + } + + @Test + fun `should return file uri when uri defined intent data for action sendto`() { + // Given + every { uri1.toString() } returns "content://test1" + val intent = mockIntent(action = Intent.ACTION_SENDTO, data = uri1) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should get single uri from intent extra stream given action is send`() { + // Given + every { uri1.toString() } returns "content://test1" + val intent = mockIntent(action = Intent.ACTION_SEND, extraStreamUri = uri1) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString())) + + // when + val fileShareInfo = intent.getShareInfo() + + // then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return empty share info when intent extra stream is null given action is send`() { + // Given + val intent = mockIntent(action = Intent.ACTION_SEND, extraStreamUri = null) + + // when + val fileShareInfo = intent.getShareInfo() + + // then + assertEquals(IntentShareInfo.Empty, fileShareInfo) + } + + @Test + fun `should return empty share info when extra stream is null and action is send`() { + // Given + val intent = mockIntent(action = Intent.ACTION_SEND, extraStreamUri = null) + + // when + val fileShareInfo = intent.getShareInfo() + + // then + assertEquals(IntentShareInfo.Empty, fileShareInfo) + } + + @Test + fun `should return uri when intent has clipData with a uri and action is send single`() { + // Given + every { uri1.toString() } returns "content://test1" + val clipData = mockk { + every { itemCount } returns 1 + every { getItemAt(0) } returns mockk { every { uri } returns uri1 } + } + val intent = mockIntent(action = Intent.ACTION_SEND, clipData = clipData) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return uri when intent has no clipData but extra stream contains uri and action is send single`() { + // Given + every { uri1.toString() } returns "content://test1" + val intent = mockIntent(action = Intent.ACTION_SEND, clipData = null, extraStreamUri = uri1) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return multiple uris when intent has clipData with list of uris and action is send multiple`() { + // Given + every { uri1.toString() } returns "content://test1" + every { uri2.toString() } returns "content://test2" + val clipData = mockk { + every { itemCount } returns 2 + every { getItemAt(0) } returns mockk { every { uri } returns uri1 } + every { getItemAt(1) } returns mockk { every { uri } returns uri2 } + } + val intent = mockIntent(action = Intent.ACTION_SEND_MULTIPLE, clipData = clipData) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString(), uri2.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return share info when intent has no clipdata but extra stream has uris and action is send multiple`() { + // Given + every { uri1.toString() } returns "content://test1" + every { uri2.toString() } returns "content://test2" + val intent = mockIntent( + action = Intent.ACTION_SEND_MULTIPLE, + extraStreamUriList = arrayListOf(uri1, uri2) + ) + val expected = IntentShareInfo.Empty.copy(attachmentUris = listOf(uri1.toString(), uri2.toString())) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should return share info with all email data when intent contains all information`() { + // Given + every { uri1.toString() } returns "content://test1" + every { uri2.toString() } returns "content://test2" + val toRecipients = arrayOf("toemail1@example.com", "toemail2@example.com") + val ccRecipients = arrayOf("ccemail1@example.com", "ccemail2@example.com") + val bccRecipients = arrayOf("bccemail1@example.com", "bccemail2@example.com") + val subject = "Test Subject" + val body = "Test Body" + + val intent = mockIntent( + action = Intent.ACTION_SEND_MULTIPLE, + extraStreamUriList = arrayListOf(uri1, uri2), + extraRecipientTo = toRecipients, + extraRecipientCc = ccRecipients, + extraRecipientBcc = bccRecipients, + extraSubject = subject, + extraText = body + ) + + val expected = IntentShareInfo.Empty.copy( + attachmentUris = listOf(uri1.toString(), uri2.toString()), + emailRecipientTo = toRecipients.toList(), + emailRecipientCc = ccRecipients.toList(), + emailRecipientBcc = bccRecipients.toList(), + emailBody = body, + emailSubject = subject + ) + + // When + val fileShareInfo = intent.getShareInfo() + + // Then + assertEquals(expected, fileShareInfo) + } + + @Test + fun `should ignore null uri when parsing attachments`() { + // Given + val clipData = mockk { + every { itemCount } returns 1 + every { getItemAt(0).uri } returns null + } + val intent = mockIntent(Intent.ACTION_SEND, clipData = clipData) + + // When + val shareInfo = intent.getShareInfo() + + // Then + assertEquals(IntentShareInfo.Empty, shareInfo) + } + + private fun mockIntent( + action: String = "", + data: Uri? = null, + clipData: ClipData? = null, + extraStreamUri: Uri? = null, + extraStreamUriList: ArrayList? = null, + extraSubject: String? = null, + extraRecipientTo: Array? = null, + extraRecipientCc: Array? = null, + extraRecipientBcc: Array? = null, + extraText: String? = null + ): Intent { + return mockk { + every { this@mockk.action } returns action + every { this@mockk.data } returns data + every { this@mockk.clipData } returns clipData + every { this@mockk.getParcelableExtra(Intent.EXTRA_STREAM) } returns extraStreamUri + every { this@mockk.getParcelableArrayListExtra(Intent.EXTRA_STREAM) } returns extraStreamUriList + every { this@mockk.getStringExtra(Intent.EXTRA_SUBJECT) } returns extraSubject + every { this@mockk.getStringArrayExtra(Intent.EXTRA_EMAIL) } returns extraRecipientTo + every { this@mockk.getStringArrayExtra(Intent.EXTRA_CC) } returns extraRecipientCc + every { this@mockk.getStringArrayExtra(Intent.EXTRA_BCC) } returns extraRecipientBcc + every { this@mockk.getStringExtra(Intent.EXTRA_TEXT) } returns extraText + } + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorageTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorageTest.kt new file mode 100644 index 0000000000..b64ba57446 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/InternalFileStorageTest.kt @@ -0,0 +1,747 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.ByteArrayInputStream +import java.io.File +import android.content.Context +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.util.kotlin.HashUtils +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class InternalFileStorageTest { + + private val contextMock = mockk { + every { filesDir } returns File(InternalStoragePath) + every { cacheDir } returns File(CachedStoragePath) + } + private val fileHelperMock = mockk() + private val internalFileStorage = InternalFileStorage(contextMock, fileHelperMock, Dispatchers.Unconfined) + + @Before + fun setUp() { + mockkObject(HashUtils) + every { HashUtils.sha256(MessageId.Raw) } returns MessageId.EncodedDigest + every { HashUtils.sha256(UserId.Raw) } returns UserId.EncodedDigest + every { HashUtils.sha256(FileIdentifier.RawFileIdentifier) } returns FileIdentifier.EncodedDigestFileIdentifier + every { + HashUtils.sha256(FileIdentifier.RawFileIdentifierNew) + } returns FileIdentifier.EncodedDigestFileIdentifierNew + } + + @After + fun tearDown() { + unmockkObject(HashUtils) + } + + @Test + fun `should read from file using a correct sanitised folder and filename and return body on success`() = runTest { + // Given + coEvery { + fileHelperMock.readFromFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns MessageBody + + // When + val actualFileContent = internalFileStorage.readFromFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertEquals(MessageBody, actualFileContent) + } + + @Test + fun `should read from cached file using a correct sanitised folder and filename and return body on success`() = + runTest { + // Given + coEvery { + fileHelperMock.readFromFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns MessageBody + + // When + val actualFileContent = internalFileStorage.readFromCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertEquals(MessageBody, actualFileContent) + } + + @Test + fun `should read from file using a correct sanitised folder and filename and return null on failure`() = runTest { + // Given + coEvery { + fileHelperMock.readFromFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns null + + // When + val actualFileContent = internalFileStorage.readFromFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertNull(actualFileContent) + } + + @Test + fun `should read from cached file using a correct sanitised folder and filename and return null on failure`() = + runTest { + // Given + coEvery { + fileHelperMock.readFromFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns null + + // When + val actualFileContent = internalFileStorage.readFromCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertNull(actualFileContent) + } + + @Test + fun `should read file using a correct sanitised folder and filename and return file on success`() = runTest { + // Given + val fileMock = mockk() + coEvery { + fileHelperMock.getFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns fileMock + + // When + val actualFile = internalFileStorage.getFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertEquals(fileMock, actualFile) + } + + @Test + fun `should read cached file using a correct sanitised folder and filename and return file on success`() = runTest { + // Given + val fileMock = mockk() + coEvery { + fileHelperMock.getFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns fileMock + + // When + val actualFile = internalFileStorage.getCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertEquals(fileMock, actualFile) + } + + @Test + fun `should read file using a correct sanitised folder and filename and return null on failure`() = runTest { + // Given + coEvery { + fileHelperMock.getFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns null + + // When + val actualFile = internalFileStorage.getFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertNull(actualFile) + } + + @Test + fun `should read cached file using a correct sanitised folder and filename and return null on failure`() = runTest { + // Given + coEvery { + fileHelperMock.getFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns null + + // When + val actualFile = internalFileStorage.getCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertNull(actualFile) + } + + @Test + fun `should write to file using a correct sanitised folder and filename and return true on success`() = runTest { + // Given + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = MessageBody + ) + } returns true + + // When + val fileWritten = internalFileStorage.writeToFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = MessageBody + ) + + // Then + assertTrue(fileWritten) + } + + @Test + fun `should write to cached file using a correct sanitised folder and filename and return true on success`() = + runTest { + // Given + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = MessageBody + ) + } returns true + + // When + val fileWritten = internalFileStorage.writeToCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = MessageBody + ) + + // Then + assertTrue(fileWritten) + } + + @Test + fun `should write to file using a correct sanitised folder and filename and return false on failure`() = runTest { + // Given + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = MessageBody + ) + } returns false + + // When + val fileWritten = internalFileStorage.writeToFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = MessageBody + ) + + // Then + assertFalse(fileWritten) + } + + @Test + fun `should write to cached file using a correct sanitised folder and filename and return false on failure`() = + runTest { + // Given + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = MessageBody + ) + } returns false + + // When + val fileWritten = internalFileStorage.writeToCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = MessageBody + ) + + // Then + assertFalse(fileWritten) + } + + @Test + fun `should write file using a correct sanitised folder and filename and return file on success`() = runTest { + // Given + val fileMock = mockk() + val fileByteArray = MessageBody.toByteArray() + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = fileByteArray + ) + } returns fileMock + + // When + val actualFile = internalFileStorage.writeFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = fileByteArray + ) + + // Then + assertEquals(fileMock, actualFile) + } + + @Test + fun `should write cached file using a correct sanitised folder and filename and return file on success`() = + runTest { + // Given + val fileMock = mockk() + val fileByteArray = MessageBody.toByteArray() + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = fileByteArray + ) + } returns fileMock + + // When + val actualFile = internalFileStorage.writeCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = fileByteArray + ) + + // Then + assertEquals(fileMock, actualFile) + } + + @Test + fun `should write file using a correct sanitised folder and filename and return null on failure`() = runTest { + // Given + val fileByteArray = MessageBody.toByteArray() + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = fileByteArray + ) + } returns null + + // When + val actualFile = internalFileStorage.writeFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = fileByteArray + ) + + // Then + assertNull(actualFile) + } + + @Test + fun `should write cached file using a correct sanitised folder and filename and return null on failure`() = + runTest { + // Given + val fileByteArray = MessageBody.toByteArray() + coEvery { + fileHelperMock.writeToFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + content = fileByteArray + ) + } returns null + + // When + val actualFile = internalFileStorage.writeCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + content = fileByteArray + ) + + // Then + assertNull(actualFile) + } + + @Test + fun `should write file via input stream and return file on success`() = runTest { + // Given + val inputStream = ByteArrayInputStream(MessageBody.toByteArray()) + val fileMock = mockk() + coEvery { + fileHelperMock.writeToFileAsStream( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + inputStream = inputStream + ) + } returns fileMock + + // When + val actual = internalFileStorage.writeFileAsStream( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + inputStream = inputStream + ) + + // Then + assertEquals(fileMock, actual) + } + + @Test + fun `should write file via input stream and return null on failure`() = runTest { + // Given + val inputStream = ByteArrayInputStream(MessageBody.toByteArray()) + coEvery { + fileHelperMock.writeToFileAsStream( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest), + inputStream = inputStream + ) + } returns null + + // When + val actual = internalFileStorage.writeFileAsStream( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw), + inputStream = inputStream + ) + + // Then + assertNull(actual) + } + + + @Test + fun `should delete a file using a correct sanitised folder and filename and return true on success`() = runTest { + // Given + coEvery { + fileHelperMock.deleteFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns true + + // When + val fileDeleted = internalFileStorage.deleteFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should delete a cached file using a correct sanitised folder and filename and return true on success`() = + runTest { + // Given + coEvery { + fileHelperMock.deleteFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns true + + // When + val fileDeleted = internalFileStorage.deleteCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should delete a file using a correct sanitised folder and filename and return false on failure`() = runTest { + // Given + coEvery { + fileHelperMock.deleteFile( + folder = FileHelper.Folder(CompleteFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns false + + // When + val fileDeleted = internalFileStorage.deleteFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertFalse(fileDeleted) + } + + @Test + fun `should delete a cached file using a correct sanitised folder and filename and return false on failure`() = + runTest { + // Given + coEvery { + fileHelperMock.deleteFile( + folder = FileHelper.Folder(CompleteCachedFolderPath), + filename = FileHelper.Filename(MessageId.EncodedDigest) + ) + } returns false + + // When + val fileDeleted = internalFileStorage.deleteCachedFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies, + fileIdentifier = InternalFileStorage.FileIdentifier(MessageId.Raw) + ) + + // Then + assertFalse(fileDeleted) + } + + @Test + fun `should delete a folder using a correct sanitised folder name and return true on success`() = runTest { + // Given + coEvery { fileHelperMock.deleteFolder(FileHelper.Folder(CompleteFolderPath)) } returns true + + // When + val fileDeleted = internalFileStorage.deleteFolder( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should delete a cached folder using a correct sanitised folder name and return true on success`() = runTest { + // Given + coEvery { fileHelperMock.deleteFolder(FileHelper.Folder(CompleteCachedFolderPath)) } returns true + + // When + val fileDeleted = internalFileStorage.deleteCachedFolder( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should delete a folder using a correct sanitised folder name and return false on success`() = runTest { + // Given + coEvery { fileHelperMock.deleteFolder(FileHelper.Folder(CompleteFolderPath)) } returns false + + // When + val fileDeleted = internalFileStorage.deleteFolder( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies + ) + + // Then + assertFalse(fileDeleted) + } + + @Test + fun `should delete a cached folder using a correct sanitised folder name and return false on success`() = runTest { + // Given + coEvery { fileHelperMock.deleteFolder(FileHelper.Folder(CompleteCachedFolderPath)) } returns false + + // When + val fileDeleted = internalFileStorage.deleteCachedFolder( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageBodies + ) + + // Then + assertFalse(fileDeleted) + } + + @Test + fun `should rename a folder using a correct sanitised folder name and return true on success`() = runTest { + // Given + val path = "/some/path/to/internal/storage/user_id_encoded/attachments" + coEvery { + fileHelperMock.renameFolder( + oldFolder = FileHelper.Folder("$path/message_id/"), + newFolder = FileHelper.Folder("$path/message_id_new/") + ) + } returns true + + // When + val fileDeleted = internalFileStorage.renameFolder( + userId = UserId.Object, + oldFolder = InternalFileStorage.Folder.MessageAttachments(MessageId.Raw), + newFolder = InternalFileStorage.Folder.MessageAttachments(MessageId.Raw + "_new") + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should rename a file use a correct sanitised folder and filename and return true on success`() = runTest { + // Given + coEvery { + fileHelperMock.renameFile( + folder = FileHelper.Folder("$CompleteAttachmentFolderPath${MessageId.Raw}/"), + oldFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifier), + newFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifierNew) + ) + } returns true + + // When + val fileDeleted = internalFileStorage.renameFile( + userId = UserId.Object, + folder = InternalFileStorage.Folder.MessageAttachments(MessageId.Raw), + oldFileIdentifier = InternalFileStorage.FileIdentifier(FileIdentifier.RawFileIdentifier), + newFileIdentifier = InternalFileStorage.FileIdentifier(FileIdentifier.RawFileIdentifierNew) + ) + + // Then + assertTrue(fileDeleted) + } + + @Test + fun `should copy a file using correct sanitized source and target folders and files`() = runTest { + // Given + val sourceFolder = MessageId.Raw + val targetFolder = MessageId.RawNew + val sourceFile = FileIdentifier.RawFileIdentifier + val targetFile = FileIdentifier.RawFileIdentifierNew + val resultFileMock = mockk() + coEvery { + fileHelperMock.copyFile( + sourceFolder = FileHelper.Folder("$CompleteCachedAttachmentFolderPath$sourceFolder/"), + sourceFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifier), + targetFolder = FileHelper.Folder("$CompleteAttachmentFolderPath$targetFolder/"), + targetFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifierNew) + ) + } returns resultFileMock + + // When + val fileCopied = internalFileStorage.copyCachedFileToNonCachedFolder( + userId = UserId.Object, + sourceFolder = InternalFileStorage.Folder.MessageAttachments(sourceFolder), + sourceFileIdentifier = InternalFileStorage.FileIdentifier(sourceFile), + targetFolder = InternalFileStorage.Folder.MessageAttachments(targetFolder), + targetFileIdentifier = InternalFileStorage.FileIdentifier(targetFile) + ) + + // Then + coVerify { + fileHelperMock.copyFile( + sourceFolder = FileHelper.Folder("$CompleteCachedAttachmentFolderPath$sourceFolder/"), + sourceFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifier), + targetFolder = FileHelper.Folder("$CompleteAttachmentFolderPath$targetFolder/"), + targetFilename = FileHelper.Filename(FileIdentifier.EncodedDigestFileIdentifierNew) + ) + } + assertEquals(resultFileMock, fileCopied) + } + + @Suppress("MaxLineLength") + private companion object TestData { + + object MessageId { + + const val Raw = "message_id" + const val RawNew = "message_id_new" + const val EncodedDigest = "message_id_encoded_digest" + } + + object FileIdentifier { + + const val RawFileIdentifier = "attachment_id" + const val RawFileIdentifierNew = "attachment_id_new" + const val EncodedDigestFileIdentifier = "attachment_id_encoded_digest" + const val EncodedDigestFileIdentifierNew = "attachment_id_encoded_digest_new" + } + + object UserId { + + const val Raw = "user_id" + val Object = UserId(Raw) + const val EncodedDigest = "user_id_encoded" + } + + const val InternalStoragePath = "/some/path/to/internal/storage" + const val CompleteFolderPath = "$InternalStoragePath/${UserId.EncodedDigest}/message_bodies/" + const val CompleteAttachmentFolderPath = "$InternalStoragePath/${UserId.EncodedDigest}/attachments/" + + const val CachedStoragePath = "/some/path/to/cache/storage" + const val CompleteCachedFolderPath = "$CachedStoragePath/${UserId.EncodedDigest}/message_bodies/" + const val CompleteCachedAttachmentFolderPath = "$CachedStoragePath/${UserId.EncodedDigest}/attachments/" + + const val MessageBody = "I am a message body" + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelperTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelperTest.kt new file mode 100644 index 0000000000..7fc376ae50 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/file/UriHelperTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.file + +import java.io.ByteArrayInputStream +import java.io.FileNotFoundException +import android.database.Cursor +import android.net.Uri +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.test.kotlin.TestDispatcherProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UriHelperTest { + + private val uri = mockk() + private val cursor = mockk(relaxUnitFun = true) { + every { getColumnIndex(any()) } returns 0 + every { moveToFirst() } returns true + every { getString(any()) } returns TestData.FileName + every { getLong(any()) } returns TestData.FileSize + } + private val byteArrayInputStream = ByteArrayInputStream(TestData.FileContent) + private val contentResolverHelper = mockk { + coEvery { openInputStream(uri) } returns byteArrayInputStream + coEvery { getType(uri) } returns TestData.FileMimeType + coEvery { query(uri) } returns cursor + } + + private val testDispatcherProvider = TestDispatcherProvider() + private val uriHelper = UriHelper(testDispatcherProvider, contentResolverHelper) + + @Test + fun `should read file content from uri`() = runTest(testDispatcherProvider.Main) { + // When + val actual = uriHelper.readFromUri(uri) + + // Then + assertEquals(byteArrayInputStream, actual) + } + + @Test + fun `should return null if reading file content from uri fails`() = runTest(testDispatcherProvider.Main) { + // Given + coEvery { contentResolverHelper.openInputStream(uri) } throws FileNotFoundException() + + // When + val actual = uriHelper.readFromUri(uri) + + // Then + assertNull(actual) + } + + @Test + fun `should get file information from uri`() = runTest(testDispatcherProvider.Main) { + // Given + val expected = FileInformation(TestData.FileName, TestData.FileSize, TestData.FileMimeType) + + // When + val actual = uriHelper.getFileInformationFromUri(uri) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `should return null if one of the file information is null`() = runTest(testDispatcherProvider.Main) { + // Given + every { cursor.getString(any()) } returns null + + // When + val actual = uriHelper.getFileInformationFromUri(uri) + + // Then + assertNull(actual) + } + + private object TestData { + + val FileContent = "I am a file content".toByteArray() + const val FileName = "image.jpg" + const val FileSize = 123L + const val FileMimeType = "image/jpeg" + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMappingTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMappingTest.kt new file mode 100644 index 0000000000..2ec7104f32 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/ApiResultEitherMappingTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +package ch.protonmail.android.mailcommon.data.mapper + +import java.net.UnknownHostException +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.mapper.fromHttpCode +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.every +import io.mockk.mockkStatic +import me.proton.core.network.domain.ApiResult +import org.json.JSONException +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApiResultEitherMappingTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + @Test + fun `returns Right on success`() { + // given + val apiResult = ApiResult.Success("value") + + // when + val result = apiResult.toEither() + + // then + assertEquals("value".right(), result) + } + + @Test + fun `returns and logs Parse Network Error on parse error`() { + // given + val cause = JSONException("message") + val apiResult = ApiResult.Error.Parse(cause) + + // when + val actual = apiResult.toEither() + + // then + val expected = DataError.Remote.Http(NetworkError.Parse, "No error message found") + assertEquals(expected.left(), actual) + loggingTestRule.assertErrorLogged("Unexpected parse error, caused by: $cause") + } + + @Test + fun `returns no internet on no internet error`() { + // given + val apiResult = ApiResult.Error.NoInternet() + + // when + val result = apiResult.toEither() + + // then + val expected = DataError.Remote.Http(NetworkError.NoNetwork, "No error message found", isRetryable = true) + assertEquals(expected.left(), result) + } + + @Test + fun `returns unreachable on connection error`() { + // given + val apiResult = ApiResult.Error.Connection(isConnectedToNetwork = false) + + // when + val result = apiResult.toEither() + + // then + val expected = DataError.Remote.Http(NetworkError.Unreachable, "No error message found", isRetryable = true) + assertEquals(expected.left(), result) + } + + @Test + fun `returns no network on connection error due to unknown host exception`() { + // given + val apiResult = ApiResult.Error.Connection( + isConnectedToNetwork = false, + cause = UnknownHostException( + "Unable to resolve host \"mail-api.proton.me\": No address associated with hostname" + ) + ) + + // when + val result = apiResult.toEither() + + // then + val expected = DataError.Remote.Http(NetworkError.NoNetwork, "No error message found", isRetryable = true) + assertEquals(expected.left(), result) + } + + @Test + fun `returns network error for http errors`() { + // given + mockkStatic(NetworkError.Companion::fromHttpCode) { + every { NetworkError.fromHttpCode(any()) } returns NetworkError.Unreachable + val protonData = ApiResult.Error.ProtonData(-1, "protonError") + val apiResult = ApiResult.Error.Http(404, "message", protonData) + + // when + val result = apiResult.toEither() + + // then + val expected = DataError.Remote.Http(NetworkError.Unreachable, "message - protonError") + assertEquals(expected.left(), result) + } + } + + @Test + fun `returns proton error for http 422 http error containing 'Message Draft Not Draft' Proton Error Code`() { + // given + mockkStatic(NetworkError.Companion::fromHttpCode) { + every { NetworkError.fromHttpCode(any()) } returns NetworkError.UnprocessableEntity + val protonData = ApiResult.Error.ProtonData( + 15_034, "Message Already Sent" + ) + val apiResult = ApiResult.Error.Http(422, "none", protonData) + + // when + val result = apiResult.toEither() + + // then + val expected = DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft) + assertEquals(expected.left(), result) + } + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappingsTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappingsTest.kt new file mode 100644 index 0000000000..9cdc98889a --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/mapper/DataStoreEitherMappingsTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.mapper + +import java.io.IOException +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.PreferencesError +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class DataStoreEitherMappingsTest { + + private val dataStore: DataStore = mockk() + private val intKey = intPreferencesKey("key") + + @Test + fun `when edit succeed`() = runTest { + // given + val preferences: Preferences = mockk() + coEvery { dataStore.updateData(any()) } returns preferences + + // when + val result = dataStore.safeEdit { + it[intKey] = 1 + } + + // then + assertEquals(preferences.right(), result) + } + + @Test + fun `when edit fails`() = runTest { + // given + coEvery { dataStore.updateData(any()) } throws IOException() + + // when + val result = dataStore.safeEdit { + it[intKey] = 1 + } + + // then + assertEquals(PreferencesError.left(), result) + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImplTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImplTest.kt new file mode 100644 index 0000000000..a9f08ee0e4 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/AppLocaleRepositoryImplTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.repository + +import java.util.Locale +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class AppLocaleRepositoryImplTest { + + private lateinit var localeRepository: AppLocaleRepositoryImpl + private lateinit var context: Context + + @Before + fun setUp() { + mockkConstructor(IntentFilter::class) + every { anyConstructed().addAction(any()) } returns mockk() + mockkStatic(AppCompatDelegate::class) + context = mockk(relaxed = true) + localeRepository = AppLocaleRepositoryImpl(context) + } + + @After + fun tearDown() { + unmockkStatic(AppCompatDelegate::class) + } + + @Test + fun `when there is a saved preferred locale it is returned`() { + // Given + val savedLocale = Locale.ITALIAN + every { AppCompatDelegate.getApplicationLocales() } returns LocaleListCompat.create(savedLocale) + // When + val actual = localeRepository.current() + // Then + assertEquals(savedLocale, actual) + } + + @Test + fun `when there are multiple saved preferred locales the first is returned`() { + // Given + val firstLocale = Locale.GERMAN + val secondLocale = Locale.ITALIAN + every { AppCompatDelegate.getApplicationLocales() } returns LocaleListCompat.create(firstLocale, secondLocale) + // When + val actual = localeRepository.current() + // Then + assertEquals(firstLocale, actual) + } + + @Test + fun `when there is no saved preferred locale the system default locale is returned`() { + // Given + val systemLocale = Locale.getDefault() + every { AppCompatDelegate.getApplicationLocales() } returns LocaleListCompat.getEmptyLocaleList() + // When + val actual = localeRepository.current() + // Then + assertEquals(systemLocale, actual) + } + + @Test + fun `refresh() should refresh the cached locale`() { + // Given + val newLocale = Locale.CANADA + every { AppCompatDelegate.getApplicationLocales() } returns LocaleListCompat.create(newLocale) + + // When + localeRepository.refresh() + + // Then + val actual = localeRepository.current() + assertEquals(newLocale, actual) + } + + @Test + fun `onReceive() should refresh the cached locale`() { + // Given + val newLocale = Locale.UK + every { AppCompatDelegate.getApplicationLocales() } returns LocaleListCompat.create(newLocale) + + // When + localeRepository.onReceive(context, Intent()) + + // Then + // Ensure that the onReceive method correctly updates the cached locale + val actual = localeRepository.current() + assertEquals(newLocale, actual) + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepositoryTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepositoryTest.kt new file mode 100644 index 0000000000..f71b8fdb2b --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/repository/UndoableOperationInMemoryRepositoryTest.kt @@ -0,0 +1,30 @@ +package ch.protonmail.android.mailcommon.data.repository + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class UndoableOperationInMemoryRepositoryTest { + + private val undoableOperationRepo = UndoableOperationInMemoryRepository() + + @Test + fun `stores and returns the last given operation in memory`() = runTest { + // Given + val lambda = { + println("logic to undo the operation") + Unit.right() + } + val expected = UndoableOperation.UndoMoveMessages(lambda) + undoableOperationRepo.storeOperation(expected) + + // When + val actual = undoableOperationRepo.getLastOperation() + + // Then + assertEquals(expected, actual) + } + +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImplTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImplTest.kt new file mode 100644 index 0000000000..3bbff822ab --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/system/DeviceCapabilitiesImplTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.system + +import android.webkit.WebView +import ch.protonmail.android.test.utils.mocks.WebViewProviderMocks.mockWebViewAvailabilityOnDevice +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class DeviceCapabilitiesImplTest { + + @Before + fun mockWebViewCheck() { + mockkStatic(WebView::class) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `Should return web view not available when provider is not present`() { + // Given + mockWebViewAvailabilityOnDevice(isPackagePresent = false) + + // When + val capabilities = DeviceCapabilitiesImpl().getCapabilities() + + // Then + assertEquals(false, capabilities.hasWebView) + } + + @Test + fun `Should return web view not available when provider is present but not enabled`() { + // Given + mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = false) + + // When + val capabilities = DeviceCapabilitiesImpl().getCapabilities() + + // Then + assertEquals(false, capabilities.hasWebView) + } + + @Test + fun `Should return web view available when provider is present and enabled`() { + // Given + mockWebViewAvailabilityOnDevice(isPackagePresent = true, isPackageEnabled = true) + + // When + val capabilities = DeviceCapabilitiesImpl().getCapabilities() + + // Then + assertEquals(true, capabilities.hasWebView) + } +} diff --git a/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/worker/EnqueuerTest.kt b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/worker/EnqueuerTest.kt new file mode 100644 index 0000000000..0a91b28f64 --- /dev/null +++ b/mail-common/data/src/test/kotlin/ch/protonmail/android/mailcommon/data/worker/EnqueuerTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.data.worker + +import java.util.UUID +import androidx.work.BackoffPolicy +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EnqueuerTest { + + private val workManager = mockk() + + private val enqueuer = Enqueuer(workManager) + + @Test + fun `keep the existing enqueued work when trying to enqueue again some unique work`() = runTest { + // Given + val workId = "SyncDraftWork-test-message-id" + val params = mapOf("messageId" to "test-message-id") + givenEnqueueUniqueWorkSucceeds(workId, ExistingWorkPolicy.KEEP) + + // When + enqueuer.enqueueUniqueWork(TestData.UserId, workId, params) + + // Then + val workPolicySlot = slot() + coVerify { workManager.enqueueUniqueWork(workId, capture(workPolicySlot), any()) } + assertEquals(ExistingWorkPolicy.KEEP, workPolicySlot.captured) + } + + @Test + fun `changes default existing work policy when explicitly requested`() = runTest { + // Given + val workId = "SyncDraftWork-test-message-id" + val params = mapOf("messageId" to "test-message-id") + val existingWorkPolicy = ExistingWorkPolicy.APPEND + givenEnqueueUniqueWorkSucceeds(workId, existingWorkPolicy) + + // When + enqueuer.enqueueUniqueWork( + TestData.UserId, + workId, + params, + existingWorkPolicy = existingWorkPolicy + ) + + // Then + val workPolicySlot = slot() + coVerify { workManager.enqueueUniqueWork(workId, capture(workPolicySlot), any()) } + assertEquals(existingWorkPolicy, workPolicySlot.captured) + } + + @Test + fun `expects linear backoff policy and duration when enqueuing some work`() = runTest { + // Given + val workId = "SyncDraftWork-test-message-id" + val params = mapOf("messageId" to "test-message-id") + val existingWorkPolicy = ExistingWorkPolicy.KEEP + givenEnqueueUniqueWorkSucceeds(workId, existingWorkPolicy) + + // When + enqueuer.enqueueUniqueWork( + TestData.UserId, + workId, + params, + existingWorkPolicy = existingWorkPolicy + ) + + // Then + val workRequest = slot() + coVerify { workManager.enqueueUniqueWork(workId, existingWorkPolicy, capture(workRequest)) } + assertEquals(workRequest.captured.workSpec.backoffPolicy, BackoffPolicy.LINEAR) + assertEquals(workRequest.captured.workSpec.backoffDelayDuration, 20_000L) + } + + @Test + fun `enqueue tags work with userId`() = runTest { + // Given + val params = mapOf("messageId" to "test-message-id") + givenEnqueueWorkSucceeds() + + // When + enqueuer.enqueue(TestData.UserId, params) + + // Then + val workRequestSlot = slot() + coVerify { workManager.enqueue(capture(workRequestSlot)) } + assertContains(workRequestSlot.captured.tags, TestData.UserId.id) + } + + @Test + fun `enqueue unique work tags work with userId`() = runTest { + // Given + val workId = "SyncDraftWork-test-message-id" + val params = mapOf("messageId" to "test-message-id") + val existingWorkPolicy = ExistingWorkPolicy.APPEND + givenEnqueueUniqueWorkSucceeds(workId, existingWorkPolicy) + + // When + enqueuer.enqueueUniqueWork( + TestData.UserId, + workId, + params, + existingWorkPolicy = existingWorkPolicy + ) + + + // Then + val workRequestSlot = slot() + coVerify { workManager.enqueueUniqueWork(workId, existingWorkPolicy, capture(workRequestSlot)) } + assertContains(workRequestSlot.captured.tags, TestData.UserId.id) + } + + @Test + fun `enqueue in chain tags work with userId`() = runTest { + // Given + val workId = "SyncDraftWork-test-message-id" + val params1 = mapOf("messageId" to "test-message-id") + val params2 = mapOf("messageId" to "test-message-id") + val existingWorkPolicy = ExistingWorkPolicy.APPEND + givenEnqueueWorkInChainSucceeds(workId, existingWorkPolicy) + + // When + enqueuer.enqueueInChain( + TestData.UserId, + workId, + params1, + params2, + existingWorkPolicy = existingWorkPolicy + ) + + + // Then + val work1RequestSlot = slot() + coVerify { workManager.beginUniqueWork(workId, existingWorkPolicy, capture(work1RequestSlot)) } + assertContains(work1RequestSlot.captured.tags, TestData.UserId.id) + } + + @Test + fun `cancel all work cancels work by tag for the given user Id`() = runTest { + // Given + val userId = UserIdSample.Primary + givenCancelWorkSucceeds(userId) + + // When + enqueuer.cancelAllWork(userId) + + // Then + coVerify { workManager.cancelAllWorkByTag(userId.id) } + } + + @Test + fun `return true when worker is enqueued`() = runTest { + // Given + val workId = "ClearLabelWorker-test-message-id" + val workInfo = WorkInfo(UUID.randomUUID(), WorkInfo.State.ENQUEUED, emptySet(), Data.EMPTY) + val expectedFlow = MutableStateFlow(listOf(workInfo)) + givenWorkManagerReturns(workId, expectedFlow) + + // When + enqueuer.observeWorkStatusIsEnqueuedOrRunning(workId).test { + // Then + assertTrue { awaitItem() } + } + } + + @Test + fun `return true when worker is running`() = runTest { + // Given + val workId = "ClearLabelWorker-test-message-id" + val workInfo = WorkInfo(UUID.randomUUID(), WorkInfo.State.ENQUEUED, emptySet(), Data.EMPTY) + val expectedFlow = MutableStateFlow(listOf(workInfo)) + givenWorkManagerReturns(workId, expectedFlow) + + // When + enqueuer.observeWorkStatusIsEnqueuedOrRunning(workId).test { + // Then + assertTrue { awaitItem() } + } + } + + @Test + fun `return false when worker is not enqueued and not running`() = runTest { + // Given + val workId = "ClearLabelWorker-test-message-id" + val workInfo = WorkInfo(UUID.randomUUID(), WorkInfo.State.SUCCEEDED, emptySet(), Data.EMPTY) + val expectedFlow = MutableStateFlow(listOf(workInfo)) + givenWorkManagerReturns(workId, expectedFlow) + + // When + enqueuer.observeWorkStatusIsEnqueuedOrRunning(workId).test { + // Then + assertFalse { awaitItem() } + } + } + + private fun givenCancelWorkSucceeds(userId: UserId) { + every { workManager.cancelAllWorkByTag(userId.id) } returns mockk() + } + + private fun givenEnqueueWorkSucceeds() { + every { workManager.enqueue(any()) } returns mockk() + } + + private fun givenEnqueueWorkInChainSucceeds(workId: String, existingWorkPolicy: ExistingWorkPolicy) { + every { + workManager.beginUniqueWork(workId, existingWorkPolicy, any()) + } returns mockk { + every { then(any()) } returns mockk { + every { enqueue() } returns mockk() + } + } + } + + private fun givenEnqueueUniqueWorkSucceeds(workId: String, existingWorkPolicy: ExistingWorkPolicy) { + every { + workManager.enqueueUniqueWork(workId, existingWorkPolicy, any()) + } returns mockk() + } + + private fun givenWorkManagerReturns(workId: String, flow: Flow>) { + every { workManager.getWorkInfosForUniqueWorkFlow(workId) } returns flow + } + + companion object { + object TestData { + + val UserId = UserIdSample.Primary + } + } + +} diff --git a/mail-common/domain/build.gradle.kts b/mail-common/domain/build.gradle.kts new file mode 100644 index 0000000000..86d9a864a0 --- /dev/null +++ b/mail-common/domain/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailcommon.domain" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + + implementation(libs.bundles.module.domain) + implementation(libs.proton.core.accountManager) + implementation(libs.proton.core.featureFlag) + implementation(libs.proton.core.label.domain) + implementation(libs.proton.core.mailSettings) + implementation(libs.proton.core.key) + implementation(libs.proton.core.user) + + testImplementation(libs.bundles.test) + testImplementation(project(":test:test-data")) + testImplementation(project(":test:utils")) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInBackgroundState.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInBackgroundState.kt new file mode 100644 index 0000000000..1f27807049 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInBackgroundState.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppInBackgroundState @Inject constructor() { + + private val _state: MutableStateFlow = MutableStateFlow(null) + + /** + * Returns the current background state of the the app. + * + * If the state is unset, it defaults to `true`. + */ + fun isAppInBackground(): Boolean = _state.value ?: true + + /** + * Will emit the current background state of the app according to lifecycle events. + */ + fun observe(): Flow = _state.asStateFlow().filterNotNull() + + @Synchronized + fun setAppInBackground(isAppInBackground: Boolean) = _state.update { isAppInBackground } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInformation.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInformation.kt new file mode 100644 index 0000000000..38603354de --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/AppInformation.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain + +data class AppInformation( + val appName: String = "Proton Mail", + val appVersionName: String = "0.0.0", + val appVersionCode: Int = 0, + val appBuildType: String = "type", + val appBuildFlavor: String = "flavor", + val appHost: String = "host" +) + +fun AppInformation.isDevOrAlphaFlavor() = appBuildFlavor == "dev" || appBuildFlavor == "alpha" diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaults.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaults.kt new file mode 100644 index 0000000000..75e8ae46a8 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaults.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain + +data class MailFeatureDefaults( + private val defaults: Map +) { + operator fun get(featureId: MailFeatureId) = defaults[featureId] ?: false +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureId.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureId.kt new file mode 100644 index 0000000000..60be92cfdd --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureId.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain + +import me.proton.core.featureflag.domain.entity.FeatureId + +/** + * This class contains all the feature flags that are used by the Mail client. + */ +enum class MailFeatureId(val id: FeatureId) { + + // Remote flags + ConversationMode(FeatureId("ThreadingAndroid")), + RatingBooster(FeatureId("RatingAndroidMail")) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracer.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracer.kt new file mode 100644 index 0000000000..5f7e24d0ae --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracer.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.benchmark + +interface BenchmarkTracer { + fun begin(name: String) + fun end() + fun beginAsync(name: String) + fun endAsync(name: String) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracerImpl.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracerImpl.kt new file mode 100644 index 0000000000..dab7cd1745 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/benchmark/BenchmarkTracerImpl.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.benchmark + +import android.os.Build +import android.os.Trace +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BenchmarkTracerImpl @Inject constructor(private val benchmarkEnabled: Boolean) : + BenchmarkTracer { + + override fun begin(name: String) { + if (benchmarkEnabled) { + Trace.beginSection(name) + } + } + + override fun end() { + if (benchmarkEnabled) { + Trace.endSection() + } + } + + override fun beginAsync(name: String) { + if (benchmarkEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Trace.beginAsyncSection(name, 0) + } else { + Timber.w("Trace.beginAsyncSection is not supported on this device") + } + } + } + + override fun endAsync(name: String) { + if (benchmarkEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Trace.endAsyncSection(name, 0) + } else { + Timber.w("Trace.endAsyncSection is not supported on this device") + } + } + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/DispatcherQualifiers.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/DispatcherQualifiers.kt new file mode 100644 index 0000000000..69f9b9af81 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/DispatcherQualifiers.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.coroutines + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IODispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MainDispatcher diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/ScopeQualifiers.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/ScopeQualifiers.kt new file mode 100644 index 0000000000..8479f71ebd --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/coroutines/ScopeQualifiers.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.coroutines + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AppScope diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/flow/FlowExtensions.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/flow/FlowExtensions.kt new file mode 100644 index 0000000000..a869319840 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/flow/FlowExtensions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +inline fun Flow.mapIfNull(crossinline default: suspend () -> T): Flow = map { it ?: default() } diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappings.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappings.kt new file mode 100644 index 0000000000..3fa0f9c6e8 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappings.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ +@file:Suppress("TooGenericExceptionThrown") + +package ch.protonmail.android.mailcommon.domain.mapper + +import java.net.UnknownHostException +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import me.proton.core.domain.arch.DataResult +import me.proton.core.network.data.ProtonErrorException +import me.proton.core.network.domain.ApiException +import retrofit2.HttpException +import timber.log.Timber + +fun Flow>.mapToEither(): Flow> = transform { dataResult -> + when (dataResult) { + is DataResult.Error.Local -> emit(toLocalDataError(dataResult).left()) + is DataResult.Error.Remote -> emit(toRemoteDataError(dataResult).left()) + is DataResult.Processing -> Unit + is DataResult.Success -> emit(dataResult.value.right()) + } +} + +private fun toLocalDataError(dataResult: DataResult.Error.Local): DataError.Local = unhandledLocalError(dataResult) + +private fun toRemoteDataError(dataResult: DataResult.Error.Remote): DataError.Remote { + return when { + dataResult.protonCode != INITIAL_ERROR_CODE -> toProtonDataError(dataResult) + dataResult.httpCode != INITIAL_ERROR_CODE -> toHttpDataError(dataResult) + else -> handleRemoteError(dataResult) + } +} + +private fun toProtonDataError(dataResult: DataResult.Error.Remote): DataError.Remote.Proton { + val protonError = ProtonError.fromProtonCode(dataResult.protonCode) + if (protonError == ProtonError.Unknown) { + Timber.e("UNHANDLED PROTON ERROR caused by result: $dataResult") + } + return DataError.Remote.Proton(protonError) +} + +private fun toHttpDataError(dataResult: DataResult.Error.Remote): DataError.Remote.Http { + val networkError = NetworkError.fromHttpCode(dataResult.httpCode) + if (networkError == NetworkError.Unknown) { + Timber.e("UNHANDLED NETWORK ERROR caused by result: $dataResult") + } + return DataError.Remote.Http(networkError, dataResult.tryExtractErrorMessage()) +} + +private fun handleRemoteError(dataResult: DataResult.Error.Remote): DataError.Remote = + when (val cause = dataResult.cause) { + is ApiException -> handleApiException(cause, dataResult) + else -> unhandledRemoteError(dataResult) + } + +private fun handleApiException(cause: ApiException, dataResult: DataResult.Error.Remote) = + when (val innerCause = cause.cause) { + is UnknownHostException -> DataError.Remote.Http(NetworkError.NoNetwork, dataResult.tryExtractErrorMessage()) + is HttpException -> toHttpDataError(dataResult.copy(httpCode = innerCause.code())) + is ProtonErrorException -> toProtonDataError(dataResult.copy(protonCode = innerCause.protonData.code)) + else -> unhandledRemoteError(dataResult) + } + +private fun DataResult.Error.Remote.tryExtractErrorMessage() = this.message ?: "No error message found" + +private fun unhandledRemoteError(dataResult: DataResult.Error.Remote): DataError.Remote.Unknown { + Timber.e("UNHANDLED REMOTE ERROR caused by result: $dataResult") + return DataError.Remote.Unknown +} + +private fun unhandledLocalError(dataResult: DataResult.Error.Local): DataError.Local.Unknown { + Timber.e("UNHANDLED LOCAL ERROR caused by result: $dataResult") + return DataError.Local.Unknown +} + +private const val INITIAL_ERROR_CODE = 0 diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappings.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappings.kt new file mode 100644 index 0000000000..3aec0f257c --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappings.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.mapper + +import ch.protonmail.android.mailcommon.domain.model.NetworkError + +@Suppress("MagicNumber") +fun NetworkError.Companion.fromHttpCode(httpCode: Int): NetworkError = when (httpCode) { + 400 -> NetworkError.BadRequest + 401 -> NetworkError.Unauthorized + 403 -> NetworkError.Forbidden + 404 -> NetworkError.NotFound + 422 -> NetworkError.UnprocessableEntity + in 500..599 -> NetworkError.ServerError + else -> NetworkError.Unknown +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/ProtonErrorMappings.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/ProtonErrorMappings.kt new file mode 100644 index 0000000000..f8225c6eee --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/mapper/ProtonErrorMappings.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.mapper + +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.model.ProtonError.AttachmentUploadMessageAlreadySent +import ch.protonmail.android.mailcommon.domain.model.ProtonError.Banned +import ch.protonmail.android.mailcommon.domain.model.ProtonError.Base64Format +import ch.protonmail.android.mailcommon.domain.model.ProtonError.Companion +import ch.protonmail.android.mailcommon.domain.model.ProtonError.InsufficientScope +import ch.protonmail.android.mailcommon.domain.model.ProtonError.MessageAlreadySent +import ch.protonmail.android.mailcommon.domain.model.ProtonError.MessageSearchQuerySyntax +import ch.protonmail.android.mailcommon.domain.model.ProtonError.MessageUpdateDraftNotDraft +import ch.protonmail.android.mailcommon.domain.model.ProtonError.MessageValidateKeyNotAssociated +import ch.protonmail.android.mailcommon.domain.model.ProtonError.PayloadTooLarge +import ch.protonmail.android.mailcommon.domain.model.ProtonError.PermissionDenied +import ch.protonmail.android.mailcommon.domain.model.ProtonError.SearchInputInvalid +import ch.protonmail.android.mailcommon.domain.model.ProtonError.Unknown +import ch.protonmail.android.mailcommon.domain.model.ProtonError.UploadFailure + +@Suppress("MagicNumber") +fun Companion.fromProtonCode(code: Int?): ProtonError = when (code) { + 2001 -> SearchInputInvalid + 2026 -> PermissionDenied + 2027 -> InsufficientScope + 2028 -> Banned + 2030 -> UploadFailure + 2031 -> PayloadTooLarge + 2063 -> Base64Format + 2500 -> MessageAlreadySent + 11_109 -> AttachmentUploadMessageAlreadySent + 15_034 -> MessageUpdateDraftNotDraft + 15_213 -> MessageValidateKeyNotAssociated + 15_225 -> MessageSearchQuerySyntax + else -> Unknown +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/Action.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/Action.kt new file mode 100644 index 0000000000..92876540e4 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/Action.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +enum class Action { + Reply, + ReplyAll, + Forward, + MarkRead, + MarkUnread, + Star, + Unstar, + Label, + Move, + Trash, + Delete, + Archive, + Spam, + ViewInLightMode, + ViewInDarkMode, + Print, + ViewHeaders, + ViewHtml, + ReportPhishing, + Remind, + SavePdf, + SenderEmails, + SaveAttachments, + More, + OpenCustomizeToolbar +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/BasicContactInfo.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/BasicContactInfo.kt new file mode 100644 index 0000000000..8158e83a19 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/BasicContactInfo.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +import ch.protonmail.android.mailcommon.domain.util.fromUrlSafeBase64String +import ch.protonmail.android.mailcommon.domain.util.toUrlSafeBase64String +import kotlinx.serialization.Serializable +import kotlin.io.encoding.Base64 + +@Serializable +data class BasicContactInfo(val contactName: String?, val contactEmail: String, val encoded: Boolean = false) + +/** + * Encodes the current [BasicContactInfo] instance's fields to [Base64.UrlSafe] strings. + */ +fun BasicContactInfo.encode(): BasicContactInfo { + return BasicContactInfo( + contactName = contactName?.toUrlSafeBase64String(), + contactEmail = contactEmail.toUrlSafeBase64String(), + encoded = true + ) +} + +/** + * Decodes the current [BasicContactInfo] instance's fields by using [Base64.UrlSafe] decoding. + */ +fun BasicContactInfo.decode(): BasicContactInfo { + return BasicContactInfo( + contactName = contactName?.fromUrlSafeBase64String(), + contactEmail = contactEmail.fromUrlSafeBase64String(), + encoded = false + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ConversationId.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ConversationId.kt new file mode 100644 index 0000000000..fd0dfb4082 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ConversationId.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +data class ConversationId(val id: String) diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DaoError.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DaoError.kt new file mode 100644 index 0000000000..3c6b531306 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DaoError.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +sealed interface DaoError { + + data class UpsertError(val throwable: Throwable) : DaoError +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DataError.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DataError.kt new file mode 100644 index 0000000000..b3c62f2b15 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/DataError.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +/** + * Errors related to Data + */ +sealed interface DataError { + + /** + * Errors related to Local persistence + */ + sealed interface Local : DataError { + + object DecryptionError : Local + + object EncryptionError : Local + + object NoDataCached : Local + + object OutOfMemory : Local + + object FailedToStoreFile : Local + + object FailedToDeleteFile : Local + + object DeletingFailed : Local + + object DbWriteFailed : Local + + /** + * This object is not meant to be actively used. + * Its purpose is to notify the logging tool that a case that should be handled + * is not and to allow dedicated handling to be put in place. + */ + object Unknown : Local + } + + /** + * Error fetching data from Remote source + */ + sealed interface Remote : DataError { + + /** + * The API returned a failure response + */ + data class Http( + val networkError: NetworkError, + val apiErrorInfo: String? = null, + val isRetryable: Boolean = false + ) : Remote + + /** + * The API returned a success, but proton code is not OK + */ + data class Proton(val protonError: ProtonError) : Remote + + /** + * This object is not meant to be actively used. + * Its purpose is to notify the logging tool that a case that should be handled + * is not and to allow dedicated handling to be put in place. + */ + object Unknown : Remote + + object CreateDraftRequestNotPerformed : Remote + } + + object AddressNotFound : DataError +} + +fun DataError.isOfflineError() = this is DataError.Remote.Http && this.networkError is NetworkError.NoNetwork + +fun DataError.isSearchInputInvalidError() = this is DataError.Remote.Proton && + this.protonError is ProtonError.SearchInputInvalid + +fun DataError.isMessageAlreadySentDraftError() = this is DataError.Remote.Proton && + this.protonError is ProtonError.MessageUpdateDraftNotDraft + +fun DataError.isMessageAlreadySentAttachmentError() = this is DataError.Remote.Proton && + this.protonError is ProtonError.AttachmentUploadMessageAlreadySent + +fun DataError.isMessageAlreadySentSendingError() = this is DataError.Remote.Proton && + this.protonError is ProtonError.MessageAlreadySent diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfo.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfo.kt new file mode 100644 index 0000000000..301b70b3fb --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfo.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +import ch.protonmail.android.mailcommon.domain.util.fromUrlSafeBase64String +import ch.protonmail.android.mailcommon.domain.util.toUrlSafeBase64String +import kotlinx.serialization.Serializable +import kotlin.io.encoding.Base64 + +@Serializable +data class IntentShareInfo( + val attachmentUris: List, + val emailSubject: String?, + val emailRecipientTo: List, + val emailRecipientCc: List, + val emailRecipientBcc: List, + val emailBody: String?, + val encoded: Boolean = false +) { + + companion object { + + val Empty = IntentShareInfo( + attachmentUris = emptyList(), + emailSubject = null, + emailRecipientTo = emptyList(), + emailRecipientCc = emptyList(), + emailRecipientBcc = emptyList(), + emailBody = null, + encoded = false + ) + } +} + +fun IntentShareInfo.isNotEmpty(): Boolean = this != IntentShareInfo.Empty +fun IntentShareInfo.hasEmailData(): Boolean = emailSubject != null || + emailBody != null || + emailRecipientTo.isNotEmpty() || + emailRecipientCc.isNotEmpty() || + emailRecipientBcc.isNotEmpty() + +/** + * Encodes the current [IntentShareInfo] instance's fields to [Base64.UrlSafe] strings. + * + * The current implementation relies on [Base64.UrlSafe] to avoid issues when passing these values as navigation + * arguments, as if standard [Base64] encoding were to add forward slashes, navigation would make the app crash. + */ +fun IntentShareInfo.encode(): IntentShareInfo { + return IntentShareInfo( + attachmentUris = attachmentUris.map { it.toUrlSafeBase64String() }, + emailSubject = emailSubject?.toUrlSafeBase64String(), + emailRecipientTo = emailRecipientTo.map { it.toUrlSafeBase64String() }, + emailRecipientCc = emailRecipientCc.map { it.toUrlSafeBase64String() }, + emailRecipientBcc = emailRecipientBcc.map { it.toUrlSafeBase64String() }, + emailBody = emailBody?.toUrlSafeBase64String(), + encoded = true + ) +} + +/** + * Decodes the current [IntentShareInfo] instance's fields by using [Base64.UrlSafe] decoding. + * + * See [IntentShareInfo.encode] for further details. + */ +fun IntentShareInfo.decode(): IntentShareInfo { + return IntentShareInfo( + attachmentUris = attachmentUris.map { it.fromUrlSafeBase64String() }, + emailSubject = emailSubject?.fromUrlSafeBase64String(), + emailRecipientTo = emailRecipientTo.map { it.fromUrlSafeBase64String() }, + emailRecipientCc = emailRecipientCc.map { it.fromUrlSafeBase64String() }, + emailRecipientBcc = emailRecipientBcc.map { it.fromUrlSafeBase64String() }, + emailBody = emailBody?.fromUrlSafeBase64String(), + encoded = false + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/NetworkError.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/NetworkError.kt new file mode 100644 index 0000000000..d475e0d4e5 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/NetworkError.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +/** + * Errors related to Network + */ +sealed interface NetworkError { + + /** + * Request is forbidden + * 403 error + */ + object Forbidden : NetworkError + + /** + * Network connectivity is not available + */ + object NoNetwork : NetworkError + + /** + * Requested url cannot be found. + * 404 error + */ + object NotFound : NetworkError + + /** + * Server has encountered an error. + * 5xx error + */ + object ServerError : NetworkError + + /** + * Request is not authorized + * 401 error + */ + object Unauthorized : NetworkError + + /** + * Requested host is not reachable + */ + object Unreachable : NetworkError + + /** + * Failed to parse the given response + */ + object Parse : NetworkError + + /** + * Request is not in the correct format + */ + object BadRequest : NetworkError + + /** + * Request is correct and understood but cannot be processed + */ + object UnprocessableEntity : NetworkError + + /** + * This object is not meant to be actively used. + * Its purpose is to notify the logging tool that a case that should be handled + * is not and to allow dedicated handling to be put in place. + */ + object Unknown : NetworkError + + companion object +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/PreferencesError.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/PreferencesError.kt new file mode 100644 index 0000000000..42f58aee00 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/PreferencesError.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +object PreferencesError diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ProtonError.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ProtonError.kt new file mode 100644 index 0000000000..72b7ac863a --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/ProtonError.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +/** + * Error related to Proton error codes + * See the `/error-codes.html` page of API docs for further details + */ +sealed interface ProtonError { + + object Base64Format : ProtonError + + object PermissionDenied : ProtonError + + object SearchInputInvalid : ProtonError + + object InsufficientScope : ProtonError + + object Banned : ProtonError + + object UploadFailure : ProtonError + + object PayloadTooLarge : ProtonError + + object MessageUpdateDraftNotDraft : ProtonError + + object MessageValidateKeyNotAssociated : ProtonError + + object MessageSearchQuerySyntax : ProtonError + + object MessageAlreadySent : ProtonError + + object AttachmentUploadMessageAlreadySent : ProtonError + + /** + * This object is not meant to be actively used. + * Its purpose is to notify the logging tool that a case that should be handled + * is not and to allow dedicated handling to be put in place. + */ + object Unknown : ProtonError + + companion object +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/UndoableOperation.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/UndoableOperation.kt new file mode 100644 index 0000000000..debfc1f3d7 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/model/UndoableOperation.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +import arrow.core.Either + +sealed class UndoableOperation( + open val undo: suspend () -> Either +) { + + data class UndoMoveMessages( + override val undo: suspend () -> Either + ) : UndoableOperation(undo) + + data class UndoMoveConversations( + override val undo: suspend () -> Either + ) : UndoableOperation(undo) + +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/AppLocaleRepository.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/AppLocaleRepository.kt new file mode 100644 index 0000000000..87fbb7c7e5 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/AppLocaleRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.repository + +import java.util.Locale + +interface AppLocaleRepository { + fun current(): Locale + fun refresh() +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/UndoableOperationRepository.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/UndoableOperationRepository.kt new file mode 100644 index 0000000000..f5e82165f4 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/repository/UndoableOperationRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.repository + +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation + +interface UndoableOperationRepository { + + suspend fun storeOperation(operation: UndoableOperation) + + suspend fun getLastOperation(): UndoableOperation? +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AccountSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AccountSample.kt new file mode 100644 index 0000000000..4d5df7261c --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AccountSample.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.account.domain.entity.Account +import me.proton.core.account.domain.entity.AccountDetails +import me.proton.core.account.domain.entity.AccountState +import me.proton.core.account.domain.entity.SessionState +import me.proton.core.network.domain.session.SessionId + +object AccountSample { + + val Primary = build() + + val PrimaryNotReady = build( + sessionId = null, + sessionState = null, + state = AccountState.NotReady + ) + + fun build( + sessionId: SessionId? = SessionIdSample.build(), + sessionState: SessionState? = SessionState.Authenticated, + state: AccountState = AccountState.Ready + ) = Account( + details = AccountDetails(session = null), + email = UserAddressSample.build().email, + sessionId = sessionId, + sessionState = sessionState, + state = state, + userId = UserIdSample.Primary, + username = UserAddressSample.build().displayName ?: UserIdSample.Primary.id + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AddressIdSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AddressIdSample.kt new file mode 100644 index 0000000000..c195dda62e --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/AddressIdSample.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.user.domain.entity.AddressId + +object AddressIdSample { + + val Primary = AddressId("primary") + + val Alias = AddressId("alias") + + val PmMeAlias = AddressId("pmMeAlias") + + val DisabledAddressId = AddressId("disabledAddress") + + val ExternalAddressId = AddressId("externalAddress") +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/ConversationIdSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/ConversationIdSample.kt new file mode 100644 index 0000000000..4a18a3a0a8 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/ConversationIdSample.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import ch.protonmail.android.mailcommon.domain.model.ConversationId + +object ConversationIdSample { + + val Invoices = ConversationId("invoices") + val WeatherForecast = ConversationId("weather_forecast") + val AlphaAppFeedback = ConversationId("alpha_app_feedback") + val Newsletter = ConversationId("newsletter") + val AppointmentReminder = ConversationId("appointment_reminder") + + fun build() = ConversationId("conversation") +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DataErrorSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DataErrorSample.kt new file mode 100644 index 0000000000..0e9c0b4e14 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DataErrorSample.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError + +object DataErrorSample { + + val NoCache = DataError.Local.NoDataCached + val Unreachable = DataError.Remote.Http(NetworkError.Unreachable) + val Offline = DataError.Remote.Http(NetworkError.NoNetwork) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DurationEpochTimeSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DurationEpochTimeSample.kt new file mode 100644 index 0000000000..b62f638b2c --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/DurationEpochTimeSample.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import kotlin.time.Duration.Companion.seconds + +object DurationEpochTimeSample { + + object Y2022 { + + object Dec { + + object D25 { + + val Midnight = 1_671_926_400L.seconds + } + } + + object Nov { + + object D14 { + + val Midnight = 1_668_384_000L.seconds + } + } + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelIdSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelIdSample.kt new file mode 100644 index 0000000000..adcb4f2a5c --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelIdSample.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.label.domain.entity.LabelId + +object LabelIdSample { + + val AllDraft = LabelId("1") + val AllMail = LabelId("5") + val AllSent = LabelId("2") + val Archive = LabelId("6") + val Document = LabelId("document") + val Folder2021 = LabelId("2021") + val Folder2022 = LabelId("2022") + val Label2021 = LabelId("Label2021") + val Label2022 = LabelId("Label2022") + val LabelCoworkers = LabelId("LabelCoworkers") + val LabelFriends = LabelId("LabelFriends") + val Inbox = LabelId("0") + val News = LabelId("news") + val Starred = LabelId("10") + val Trash = LabelId("3") + val Spam = LabelId("4") + + fun build() = LabelId("label") +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelSample.kt new file mode 100644 index 0000000000..c2e520e5d2 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/LabelSample.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.Label +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.label.domain.entity.LabelType + +object LabelSample { + + val AllDrafts = build( + labelId = LabelIdSample.AllDraft, + type = LabelType.MessageFolder + ) + val Archive = build( + labelId = LabelIdSample.Archive, + type = LabelType.MessageFolder + ) + val Document = build( + labelId = LabelIdSample.Document + ) + val Folder2021 = build( + labelId = LabelIdSample.Folder2021, + type = LabelType.MessageFolder, + color = "#FF0000" + ) + val Folder2022 = build( + labelId = LabelIdSample.Folder2022, + type = LabelType.MessageFolder + ) + val Label2021 = build( + labelId = LabelIdSample.Label2021, + type = LabelType.MessageLabel + ) + val Label2022 = build( + labelId = LabelIdSample.Label2022, + type = LabelType.MessageLabel + ) + val Inbox = build( + labelId = LabelIdSample.Inbox, + type = LabelType.MessageFolder + ) + val News = build( + labelId = LabelIdSample.News + ) + val Starred = build( + labelId = LabelIdSample.Starred + ) + + val Parent = build( + labelId = LabelIdSample.Folder2021 + ) + + val FirstChild = build( + labelId = LabelIdSample.Folder2022, + parentId = LabelIdSample.Folder2021 + ) + + val SecondChild = build( + labelId = LabelIdSample.Label2022, + parentId = LabelIdSample.Folder2022 + ) + + val GroupCoworkers = build( + labelId = LabelIdSample.LabelCoworkers, + color = "#AABBCC" + ) + + val GroupFriends = build( + labelId = LabelIdSample.LabelFriends, + color = "#CCBBAA" + ) + + fun build( + color: String = "#338AF3", + isExpanded: Boolean? = null, + labelId: LabelId = LabelIdSample.build(), + name: String = labelId.id, + order: Int = labelId.id.hashCode(), + parentId: LabelId? = null, + type: LabelType = LabelType.MessageLabel, + userId: UserId = UserIdSample.Primary + ) = Label( + color = color, + isExpanded = isExpanded, + isNotified = null, + isSticky = null, + labelId = labelId, + name = name, + order = order, + parentId = parentId, + path = labelId.id, + type = type, + userId = userId + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionIdSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionIdSample.kt new file mode 100644 index 0000000000..7693d69fce --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionIdSample.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.network.domain.session.SessionId + +object SessionIdSample { + + fun build() = SessionId("session") +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionSample.kt new file mode 100644 index 0000000000..9083ed8372 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/SessionSample.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.network.domain.session.Session + +object SessionSample { + + val Primary = build() + + fun build() = Session.Authenticated( + userId = UserIdSample.Primary, + accessToken = "accessToken", + refreshToken = "refreshToken", + scopes = listOf("Mail"), + sessionId = SessionIdSample.build() + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserAddressSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserAddressSample.kt new file mode 100644 index 0000000000..7bc7ebc658 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserAddressSample.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.user.domain.entity.AddressId +import me.proton.core.user.domain.entity.AddressType +import me.proton.core.user.domain.entity.UserAddress + +object UserAddressSample { + + val PrimaryAddress = build() + + val AliasAddress = build( + addressId = AddressIdSample.Alias, + addressType = AddressType.Alias, + email = "alias@protonmail.ch", + order = 1 + ) + + val DisabledAddress = build( + addressId = AddressIdSample.DisabledAddressId, + addressType = AddressType.Alias, + email = "disabled@protonmail.ch", + order = 2, + enabled = false + ) + + val ExternalAddressWithSend = build( + addressId = AddressIdSample.ExternalAddressId, + addressType = AddressType.External, + email = "external@gmail.com", + order = 2, + canSend = true + ) + + val ExternalAddressWithoutSend = build( + addressId = AddressIdSample.ExternalAddressId, + addressType = AddressType.External, + email = "external@gmail.com", + order = 2, + canSend = false + ) + + val PmMeAddressAlias = build( + addressId = AddressIdSample.PmMeAlias, + addressType = AddressType.Premium, + email = "myaddress@pm.me", + order = 3 + ) + + fun build( + addressId: AddressId = AddressIdSample.Primary, + email: String = "primary-email@pm.me", + order: Int = 0, + enabled: Boolean = true, + addressType: AddressType = AddressType.Original, + canSend: Boolean = true + ) = UserAddress( + addressId = addressId, + canReceive = true, + canSend = canSend, + displayName = "name", + email = email, + enabled = enabled, + keys = emptyList(), + type = addressType, + order = order, + signature = "signature", + signedKeyList = null, + userId = UserIdSample.Primary + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserIdSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserIdSample.kt new file mode 100644 index 0000000000..8c592f90e9 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserIdSample.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.domain.entity.UserId + +object UserIdSample { + + val Primary = UserId("primary") +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserSample.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserSample.kt new file mode 100644 index 0000000000..d2912b0840 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/sample/UserSample.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.sample + +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.entity.key.KeyId +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.user.domain.entity.Delinquent +import me.proton.core.user.domain.entity.Role +import me.proton.core.user.domain.entity.Type +import me.proton.core.user.domain.entity.User +import me.proton.core.user.domain.entity.UserKey + +object UserSample { + + val Primary = build() + + val UserWithKeys = build().copy( + keys = listOf( + UserKey( + userId = UserIdSample.Primary, + version = 1, + keyId = KeyId("userKey"), + privateKey = PrivateKey( + key = "private key armored", + isPrimary = true, + passphrase = EncryptedByteArray("private key passphrase".toByteArray()) + ) + ) + ) + ) + + fun build( + name: String = AccountSample.build().username ?: "name", + displayName: String = name, + email: String = AccountSample.build().email ?: "email", + role: Role = Role.NoOrganization, + userId: UserId = UserIdSample.Primary + ) = User( + type = Type.Proton, + credit = 1, + createdAtUtc = 0, + currency = "CHF", + delinquent = Delinquent.None, + displayName = displayName, + email = email, + keys = emptyList(), + flags = emptyMap(), + maxSpace = 1_024, + maxUpload = 1, + name = name, + private = true, + role = role, + services = 1, + subscribed = 1, + usedSpace = 512, + userId = userId, + recovery = null + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/BuildVersionProvider.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/BuildVersionProvider.kt new file mode 100644 index 0000000000..2274c3eec1 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/BuildVersionProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.system + +interface BuildVersionProvider { + + fun sdkInt(): Int + +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/ContentValuesProvider.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/ContentValuesProvider.kt new file mode 100644 index 0000000000..db12582834 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/ContentValuesProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.system + +import android.content.ContentValues + +interface ContentValuesProvider { + + fun provideContentValues(): ContentValues + +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/DeviceCapabilities.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/DeviceCapabilities.kt new file mode 100644 index 0000000000..d6ad356e35 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/system/DeviceCapabilities.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.system + +interface DeviceCapabilities { + + fun getCapabilities(): Capabilities + + data class Capabilities( + val hasWebView: Boolean + ) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocale.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocale.kt new file mode 100644 index 0000000000..32ea14f61d --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocale.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import java.util.Locale +import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository +import javax.inject.Inject + +class GetAppLocale @Inject constructor( + private val appLocaleRepository: AppLocaleRepository +) { + + operator fun invoke(): Locale = appLocaleRepository.current() +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDuration.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDuration.kt new file mode 100644 index 0000000000..2047fd053b --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDuration.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class GetCurrentEpochTimeDuration @Inject constructor( + private val getLocalisedCalendar: GetLocalisedCalendar +) { + + operator fun invoke(): Duration = (getLocalisedCalendar().timeInMillis / MsInASec).seconds + + companion object { + + const val MsInASec = 1_000L + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendar.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendar.kt new file mode 100644 index 0000000000..20d323f45d --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendar.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import java.util.Calendar +import javax.inject.Inject + +class GetLocalisedCalendar @Inject constructor( + private val getAppLocale: GetAppLocale +) { + + operator fun invoke(): Calendar = Calendar.getInstance(getAppLocale()) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddress.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddress.kt new file mode 100644 index 0000000000..429ad88fc7 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddress.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject + +class GetPrimaryAddress @Inject constructor( + private val observeUserAddresses: ObserveUserAddresses +) { + + suspend operator fun invoke(userId: UserId): Either { + val addresses = observeUserAddresses.invoke(userId).first() + if (addresses.isEmpty()) { + return DataError.Local.NoDataCached.left() + } + + val primary = addresses.minBy { it.order } + return primary.right() + } + +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperation.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperation.kt new file mode 100644 index 0000000000..1ad5b5dc6a --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperation.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import javax.inject.Inject + +class GetUndoableOperation @Inject constructor( + private val undoableOperationRepository: UndoableOperationRepository +) { + + suspend operator fun invoke() = undoableOperationRepository.getLastOperation() +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUser.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUser.kt new file mode 100644 index 0000000000..398d29f73c --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUser.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.extension.hasSubscriptionForMail +import javax.inject.Inject + +class IsPaidMailUser @Inject constructor( + private val observeUser: ObserveUser +) { + + suspend operator fun invoke(userId: UserId): Either { + val user = observeUser(userId).first() ?: return DataError.Local.Unknown.left() + return user.hasSubscriptionForMail().right() + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUser.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUser.kt new file mode 100644 index 0000000000..17c00bea11 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUser.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.extension.hasSubscription +import javax.inject.Inject + +class IsPaidUser @Inject constructor( + private val observeUser: ObserveUser +) { + + suspend operator fun invoke(userId: UserId): Either { + val user = observeUser(userId).first() ?: return DataError.Local.Unknown.left() + return user.hasSubscription().right() + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeature.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeature.kt new file mode 100644 index 0000000000..1f5052fa31 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeature.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import ch.protonmail.android.mailcommon.domain.flow.mapIfNull +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureFlag +import me.proton.core.featureflag.domain.entity.Scope +import javax.inject.Inject + +class ObserveMailFeature @Inject constructor( + private val featureFlagManager: FeatureFlagManager, + private val mailFeatureDefaults: MailFeatureDefaults +) { + + operator fun invoke(userId: UserId, feature: MailFeatureId): Flow = + featureFlagManager.observe(userId, feature.id) + .mapIfNull { + val defaultValue = mailFeatureDefaults[feature] + FeatureFlag( + userId = userId, + featureId = feature.id, + value = defaultValue, + scope = Scope.Unknown, + defaultValue = defaultValue + ) + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUser.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUser.kt new file mode 100644 index 0000000000..62a7d7a7c8 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUser.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.user.domain.UserManager +import javax.inject.Inject + +class ObservePrimaryUser @Inject constructor( + val accountManager: AccountManager, + val userManager: UserManager +) { + + operator fun invoke() = accountManager.getPrimaryUserId() + .flatMapLatest { userId -> + if (userId == null) flowOf(null) + else userManager.observeUser(userId) + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserId.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserId.kt new file mode 100644 index 0000000000..fb4b9d7729 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserId.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObservePrimaryUserId @Inject constructor( + private val accountManager: AccountManager +) { + + operator fun invoke(): Flow = accountManager.getPrimaryUserId() +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUser.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUser.kt new file mode 100644 index 0000000000..8b758f0212 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUser.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 Proton Technologies AG + * This file is part of Proton Technologies AG and ProtonMail. + * + * ProtonMail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonMail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonMail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.entity.User +import javax.inject.Inject + +class ObserveUser @Inject constructor( + val userManager: UserManager +) { + + operator fun invoke(userId: UserId): Flow = userManager.observeUser(userId) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddresses.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddresses.kt new file mode 100644 index 0000000000..2ecda54cda --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddresses.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject + +class ObserveUserAddresses @Inject constructor( + private val userManager: UserManager +) { + + operator fun invoke(userId: UserId): Flow> = userManager.observeAddresses(userId) + +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperation.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperation.kt new file mode 100644 index 0000000000..3e531bef89 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperation.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import javax.inject.Inject + +class RegisterUndoableOperation @Inject constructor( + private val undoableOperationRepository: UndoableOperationRepository +) { + + suspend operator fun invoke(operation: UndoableOperation) = undoableOperationRepository.storeOperation(operation) +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ResolveUserAddress.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ResolveUserAddress.kt new file mode 100644 index 0000000000..27e6a051c9 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ResolveUserAddress.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.AddressId +import me.proton.core.user.domain.entity.UserAddress +import timber.log.Timber +import javax.inject.Inject + +class ResolveUserAddress @Inject constructor( + private val observeUserAddresses: ObserveUserAddresses +) { + + suspend operator fun invoke(userId: UserId, email: String): Either { + val userAddress = observeUserAddresses(userId).first().find { it.email == email } + if (userAddress == null) { + Timber.e("Could not resolve user address for email: $email") + return Error.UserAddressNotFound.left() + } + + return userAddress.right() + } + + suspend operator fun invoke(userId: UserId, addressId: AddressId): Either { + val userAddress = observeUserAddresses(userId).first().find { it.addressId == addressId } + if (userAddress == null) { + Timber.e("Could not resolve user address for address ID: ${addressId.id}") + return Error.UserAddressNotFound.left() + } + + return userAddress.right() + } + + sealed interface Error { + object UserAddressNotFound : Error + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/UndoLastOperation.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/UndoLastOperation.kt new file mode 100644 index 0000000000..c56c0e5f2e --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/usecase/UndoLastOperation.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.Either +import arrow.core.left +import timber.log.Timber +import javax.inject.Inject + +class UndoLastOperation @Inject constructor( + private val getUndoableOperation: GetUndoableOperation +) { + + suspend operator fun invoke(): Either { + val operation = getUndoableOperation() + + if (operation == null) { + Timber.w("Undo operation requested but no operation to undo was found") + return Error.NoOperationToUndo.left() + } + + return operation.undo() + .mapLeft { Error.UndoFailed } + } + + sealed interface Error { + data object NoOperationToUndo : Error + data object UndoFailed : Error + } +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/EitherUtils.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/EitherUtils.kt new file mode 100644 index 0000000000..d3ac731d77 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/EitherUtils.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.util + +import arrow.core.Either +import arrow.core.left +import arrow.core.right + +fun Boolean.mapFalse(block: () -> T): Either = if (this) Unit.right() else block().left() diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/Preconditions.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/Preconditions.kt new file mode 100644 index 0000000000..fb9912b1f7 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/Preconditions.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.util + +/** + * Throws an [IllegalArgumentException] if the value is `null` or a blank String. + * Otherwise returns the not null value. + * + * @param fieldName Optional name of the field + * + * @sample `requireNotBlank(userId, "User id")` -> "User id was null" / "User id was blank" + */ +fun requireNotBlank(value: String?, fieldName: String? = null): String { + requireNotNull(value) { "${fieldName ?: "Required value"} was null." } + require(value.isNotBlank()) { "${fieldName ?: "Required value"} was blank." } + return value +} + +/** + * Throws an [IllegalArgumentException] if the value is `null` or an empty String. + * Otherwise returns the not null value. + * + * @param fieldName Optional name of the field + * + * @sample `requireNotEmpty(userId, "User id")` -> "User id was null." / "User id was empty." + */ +fun requireNotEmpty(value: String?, fieldName: String? = null): String { + requireNotNull(value) { "${fieldName ?: "Required value"} was null." } + require(value.isNotEmpty()) { "${fieldName ?: "Required value"} was empty." } + return value +} diff --git a/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/StringUtils.kt b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/StringUtils.kt new file mode 100644 index 0000000000..3ba30701c9 --- /dev/null +++ b/mail-common/domain/src/main/kotlin/ch/protonmail/android/mailcommon/domain/util/StringUtils.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.util + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +fun String.toUrlSafeBase64String() = Base64.UrlSafe.encode(this.toByteArray()) + +@OptIn(ExperimentalEncodingApi::class) +fun String.fromUrlSafeBase64String() = String(Base64.UrlSafe.decode(source = this)) diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/AppInBackgroundStateTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/AppInBackgroundStateTest.kt new file mode 100644 index 0000000000..8636afa5fe --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/AppInBackgroundStateTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon + +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class AppInBackgroundStateTest { + + @Test + fun `should emit nothing when first instantiated`() = runTest { + // Given + val appInBackgroundState = AppInBackgroundState() + + // When + Then + appInBackgroundState.observe().test { + expectNoEvents() + } + } + + @Test + fun `should emit the correct value when set`() = runTest { + // Given + val appInBackgroundState = AppInBackgroundState() + val expectedResult = false + + // When + appInBackgroundState.setAppInBackground(isAppInBackground = expectedResult) + + // Then + appInBackgroundState.observe().test { + assertEquals(expectedResult, awaitItem()) + } + } + + @Test + fun `should default to true when fetching the background value directly when first instantiated`() { + // Given + val appInBackgroundState = AppInBackgroundState() + val expectedResult = true + + // When + val actual = appInBackgroundState.isAppInBackground() + + // Then + assertEquals(expectedResult, actual) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaultsTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaultsTest.kt new file mode 100644 index 0000000000..ca326ccd68 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/MailFeatureDefaultsTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain + +import org.junit.Test +import kotlin.test.assertTrue + +class MailFeatureDefaultsTest { + + @Test + fun `should return the default value if found in the provided defaults or false otherwise`() { + // Given + val defaultsMap = mapOf( + MailFeatureId.ConversationMode to true, + MailFeatureId.RatingBooster to false + ) + val defaults = MailFeatureDefaults(defaultsMap) + + // When/Then + assertTrue(defaults[MailFeatureId.ConversationMode]) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappingsTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappingsTest.kt new file mode 100644 index 0000000000..c2a9ccb933 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/DataResultEitherMappingsTest.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.mapper + +import java.net.UnknownHostException +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.arch.DataResult +import me.proton.core.domain.arch.ResponseSource +import me.proton.core.network.data.ProtonErrorException +import me.proton.core.network.domain.ApiException +import me.proton.core.network.domain.ApiResult +import me.proton.core.util.kotlin.EMPTY_STRING +import org.junit.Rule +import retrofit2.HttpException +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class DataResultEitherMappingsTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + @Test + fun `emits data result value on success`() = runTest { + // given + val string1 = "hello" + val string2 = "world" + val input = flowOf( + DataResult.Success(ResponseSource.Local, string1), + DataResult.Success(ResponseSource.Remote, string2) + ) + // when + input.mapToEither().test { + // then + assertEquals(string1.right(), awaitItem()) + assertEquals(string2.right(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `does not emit anything on loading`() = runTest { + // given + val string = "hello" + val input = flowOf( + DataResult.Processing(ResponseSource.Remote), + DataResult.Success(ResponseSource.Remote, string) + ) + // when + input.mapToEither().test { + // then + assertEquals(string.right(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `does log and return unknown local error for unhandled local error`() = runTest { + // given + val message = "an error occurred" + val dataResult = DataResult.Error.Local(message, cause = Exception("Unknown exception")) + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + assertEquals(DataError.Local.Unknown.left(), awaitItem()) + loggingTestRule.assertErrorLogged("UNHANDLED LOCAL ERROR caused by result: $dataResult") + awaitComplete() + } + } + + @Test + fun `does emit not found network error for remote http code 404`() = runTest { + // given + val input = flowOf(DataResult.Error.Remote(message = null, cause = null, httpCode = 404)) + // when + input.mapToEither().test { + // then + assertEquals(DataError.Remote.Http(NetworkError.NotFound, "No error message found").left(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `does log and return unknown http error for unhandled http error`() = runTest { + // given + val dataResult = DataResult.Error.Remote(message = null, cause = null, httpCode = 456) + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + val expected = DataError.Remote.Http( + NetworkError.Unknown, "No error message found" + ).left() + assertEquals(expected, awaitItem()) + loggingTestRule.assertErrorLogged("UNHANDLED NETWORK ERROR caused by result: $dataResult") + awaitComplete() + } + } + + @Test + fun `does log and return unknown proton error for unhandled proton error`() = runTest { + // given + val dataResult = DataResult.Error.Remote(message = null, cause = null, protonCode = 123) + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + val expected = DataError.Remote.Proton(ProtonError.Unknown).left() + assertEquals(expected, awaitItem()) + loggingTestRule.assertErrorLogged("UNHANDLED PROTON ERROR caused by result: $dataResult") + awaitComplete() + } + } + + @Test + fun `does log and return unknown remote error for unhandled remote error`() = runTest { + // given + val message = "an error occurred" + val dataResult = DataResult.Error.Remote(message = message, cause = Exception("Unknown exception")) + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + assertEquals(DataError.Remote.Unknown.left(), awaitItem()) + loggingTestRule.assertErrorLogged("UNHANDLED REMOTE ERROR caused by result: $dataResult") + awaitComplete() + } + } + + @Test + fun `does log and return Unknown remote error nested exception is unknown`() = runTest { + // given + val cause = ApiException( + ApiResult.Error.Http( + cause = Exception("Unknown exception"), + httpCode = 0, + message = EMPTY_STRING + ) + ) + val dataResult = DataResult.Error.Remote(message = null, cause = cause) + val expectedError = DataError.Remote.Unknown + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + assertEquals(expectedError.left(), awaitItem()) + loggingTestRule.assertErrorLogged("UNHANDLED REMOTE ERROR caused by result: $dataResult") + awaitComplete() + } + } + + @Test + fun `does handle nested UnknownHostException`() = runTest { + // given + val cause = ApiException( + ApiResult.Error.Http( + cause = UnknownHostException(), + httpCode = 0, + message = EMPTY_STRING + ) + ) + val dataResult = DataResult.Error.Remote(message = null, cause = cause) + val expectedError = DataError.Remote.Http(NetworkError.NoNetwork, "No error message found") + val input = flowOf(dataResult) + // when + input.mapToEither().test { + // then + assertEquals(expectedError.left(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `does handle nested Http Exceptions mapping them to http errors`() = runTest { + // given + val errorMessage = "HTTP 505 HTTP Version Not Supported" + val httpException = mockk { + every { this@mockk.message } returns errorMessage + every { code() } returns 505 + } + val input = flowOf( + DataResult.Error.Remote( + message = errorMessage, + cause = ApiException(ApiResult.Error.Parse(httpException)), + httpCode = 0 + ) + ) + // when + input.mapToEither().test { + // then + val http = DataError.Remote.Http(NetworkError.ServerError, errorMessage) + assertEquals(http.left(), awaitItem()) + awaitComplete() + } + } + + @Test + fun `does handle nested remote Proton Exception mapping it to Proton Error`() = runTest { + // given + val protonException = ProtonErrorException( + response = mockk(), + protonData = ApiResult.Error.ProtonData(2063, "Base64 data has bad format") + ) + val input = flowOf( + DataResult.Error.Remote( + message = "Wrapping error message", + cause = ApiException( + ApiResult.Error.Http( + httpCode = 0, + message = "", + cause = protonException + ) + ), + httpCode = 0 + ) + ) + // when + input.mapToEither().test { + // then + assertEquals(DataError.Remote.Proton(ProtonError.Base64Format).left(), awaitItem()) + awaitComplete() + } + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappingsTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappingsTest.kt new file mode 100644 index 0000000000..95522a7b88 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/mapper/HttpCodeNetworkErrorMappingsTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.mapper + +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class HttpCodeNetworkErrorMappingsTest { + + @Test + fun `does return unauthorized for 401 errors`() { + // given + val expected = NetworkError.Unauthorized + + // when + val result = NetworkError.fromHttpCode(401) + + // then + assertEquals(expected, result) + } + + @Test + fun `does return forbidden for 403 errors`() { + // given + val expected = NetworkError.Forbidden + + // when + val result = NetworkError.fromHttpCode(403) + + // then + assertEquals(expected, result) + } + + @Test + fun `does return not found for 404 errors`() { + // given + val expected = NetworkError.NotFound + + // when + val result = NetworkError.fromHttpCode(404) + + // then + assertEquals(expected, result) + } + + @Test + fun `does return unprocessable entity for 422 errors`() { + // given + val expected = NetworkError.UnprocessableEntity + + // when + val result = NetworkError.fromHttpCode(422) + + // then + assertEquals(expected, result) + } + + @Test + fun `does return server error for 5xx errors`() { + // given + val expected = NetworkError.ServerError + + // when + for (httpCode in 500..599) { + val result = NetworkError.fromHttpCode(httpCode) + + // then + assertEquals(expected, result) + } + } + + @Test + fun `does return Unknown for unknown errors`() { + // given + val unknownCodes = generateRandoms(0..399) + + generateRandoms(405..421) + + generateRandoms(423..499) + + generateRandoms(600..999) + // when + for (httpCode in unknownCodes) { + val result = NetworkError.fromHttpCode(httpCode) + // then + assertEquals(NetworkError.Unknown, result) + } + } + + private fun generateRandoms(range: IntRange, howMany: Int = 100): Set = (0 until howMany).map { + range.random() + }.toSet() +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/DataErrorExtensionsTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/DataErrorExtensionsTest.kt new file mode 100644 index 0000000000..cfe9743255 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/DataErrorExtensionsTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class DataErrorExtensionsTest { + + @Test + fun `data error is an offline error when type is Remote http and contains No Network Error`() { + // given + val dataError = DataError.Remote.Http(NetworkError.NoNetwork) + // then + assertTrue(dataError.isOfflineError()) + } + + @Test + fun `data error is not an offline error when type is Remote http and does not contain No Network Error`() { + // given + val dataError = DataError.Remote.Http(NetworkError.ServerError) + // then + assertFalse(dataError.isOfflineError()) + } + + @Test + fun `data error is not an offline error when type is Local`() { + // given + val dataError = DataError.Local.NoDataCached + // then + assertFalse(dataError.isOfflineError()) + } + + @Test + fun `data error is a message already sent error when type is Remote Proton and contains DraftNotDraftError`() { + // given + val dataError = DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft) + // then + assertTrue(dataError.isMessageAlreadySentDraftError()) + } + + @Test + fun `is not a message already sent error when type is Remote Proton and does not contain DraftNotDraftError`() { + // given + val dataError = DataError.Remote.Proton(ProtonError.Banned) + // then + assertFalse(dataError.isMessageAlreadySentDraftError()) + } + + @Test + fun `is a message already sent error when type is Remote Proton and contains AttachmentMessageAlreadySentError`() { + // given + val dataError = DataError.Remote.Proton(ProtonError.AttachmentUploadMessageAlreadySent) + // then + assertTrue(dataError.isMessageAlreadySentAttachmentError()) + } + + @Test + fun `is a message already sent error when type is Remote Proton and contains MessageAlreadySentError`() { + // given + val dataError = DataError.Remote.Proton(ProtonError.MessageAlreadySent) + // then + assertTrue(dataError.isMessageAlreadySentSendingError()) + } + + @Test + fun `is not a message already sent error when type is Local`() { + // given + val dataError = DataError.Local.NoDataCached + // then + assertFalse(dataError.isMessageAlreadySentDraftError()) + } + + @Test + fun `is search input invalid error when type is Remote proton of type SearchInputInvalid`() { + // given + val dataError = DataError.Remote.Proton(ProtonError.SearchInputInvalid) + // then + assertTrue(dataError.isSearchInputInvalidError()) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfoTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfoTest.kt new file mode 100644 index 0000000000..f261345d23 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/model/IntentShareInfoTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.model + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class IntentShareInfoTest { + + private val uri1 = mockk() + private val uri2 = mockk() + + @Test + fun `encode and decode should result in the same data content`() { + // Given + every { uri1.toString() } returns "content://test1" + every { uri2.toString() } returns "content://test2" + val toRecipients = arrayOf("toemail1@example.com", "toemail2@example.com") + val ccRecipients = arrayOf("ccemail1@example.com", "ccemail2@example.com") + val bccRecipients = arrayOf("bccemail1@example.com", "bccemail2@example.com") + val subject = "Test Subject" + val body = "Test Body" + val intentShareInfo = IntentShareInfo.Empty.copy( + attachmentUris = listOf(uri1.toString(), uri2.toString()), + emailRecipientTo = toRecipients.toList(), + emailRecipientCc = ccRecipients.toList(), + emailRecipientBcc = bccRecipients.toList(), + emailBody = body, + emailSubject = subject + ) + + // When + val encodedDecodedIntentShareInfo = intentShareInfo.encode().decode() + + // Then + assertEquals(intentShareInfo, encodedDecodedIntentShareInfo) + } + + @Test + fun `encode should not add forward slashes when encoding very long content`() { + // Given + every { uri1.toString() } returns "content://test1" + every { uri2.toString() } returns "content://test2" + val toRecipients = arrayOf("toemail1@example.com", "toemail2@example.com") + val ccRecipients = arrayOf("ccemail1@example.com", "ccemail2@example.com") + val bccRecipients = arrayOf("bccemail1@example.com", "bccemail2@example.com") + val subject = "Test Subject" + val body = AVeryLongString + val intentShareInfo = IntentShareInfo.Empty.copy( + attachmentUris = listOf(uri1.toString(), uri2.toString()), + emailRecipientTo = toRecipients.toList(), + emailRecipientCc = ccRecipients.toList(), + emailRecipientBcc = bccRecipients.toList(), + emailBody = body, + emailSubject = subject + ) + + // When + val encodedFileShareInfo = intentShareInfo.encode() + val encodedDecodedIntentShareInfo = encodedFileShareInfo.decode() + + // Then + assertFalse(encodedFileShareInfo.emailBody!!.contains("/")) + assertEquals(intentShareInfo, encodedDecodedIntentShareInfo) + } + + private companion object { + + @Suppress("MaxLineLength") + const val AVeryLongString = + "Lorem ipsum の痛みは改善され、エリートの脂肪が蓄積され、一時的に痛みが発生し、労働力と痛みが大きくなります。 iaculis nunc sed augueのウルトリス。アンティのティンシダント・イド・アリケット・リスス・フェウギア。 Vitae tortor condimentum lacinia quis vel eros donec。ウルナのテルスにあるscelerisque purus semper eget duis。 Ut sem nulla pharetra diam sit amet.オルナーレのEnim lobortis scelerisque fermentum dui faucibus。ペレンテスクの生息地モルビ トリスティック セネクトゥスとネットスなど。テルスのリスス・ビベラ・アディピシングで。 nisl nisi scelerisqueのViverra accumsan。" + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocaleTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocaleTest.kt new file mode 100644 index 0000000000..55960fdffd --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetAppLocaleTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import java.util.Locale +import ch.protonmail.android.mailcommon.domain.repository.AppLocaleRepository +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals + +class GetAppLocaleTest { + + private val appLocaleRepository = mockk() + + private val getAppLocale = GetAppLocale(appLocaleRepository) + + @Test + fun `returns locale provided by app locale repository`() { + val appLocale = Locale.UK + every { appLocaleRepository.current() } returns appLocale + + val actual = getAppLocale() + + assertEquals(appLocale, actual) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDurationTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDurationTest.kt new file mode 100644 index 0000000000..53abfdacd1 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetCurrentEpochTimeDurationTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.DurationEpochTimeSample +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class GetCurrentEpochTimeDurationTest { + + private val getLocalisedCalendar: GetLocalisedCalendar = mockk() + private val getCurrentEpochTimeDuration = GetCurrentEpochTimeDuration( + getLocalisedCalendar = getLocalisedCalendar + ) + + @Test + fun `when system time is Xms 2022 midnight, then correct epoch time is returned`() { + // given + every { getLocalisedCalendar().timeInMillis } returns 1_671_926_400_000L + val expected = DurationEpochTimeSample.Y2022.Dec.D25.Midnight + + // when + val actual = getCurrentEpochTimeDuration() + + // then + assertEquals(expected, actual) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendarTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendarTest.kt new file mode 100644 index 0000000000..f1e885f267 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetLocalisedCalendarTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import java.util.Calendar +import java.util.Locale +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals + +class GetLocalisedCalendarTest { + + private val getAppLocale = mockk { + every { this@mockk.invoke() } returns Locale.CHINA + } + + private val getLocalisedCalendar = GetLocalisedCalendar(getAppLocale) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `gets calendar instance based on current locale`() { + // Given + mockkStatic(Calendar::class) + // When + getLocalisedCalendar() + // Then + val slot = slot() + verify { Calendar.getInstance(capture(slot)) } + assertEquals(Locale.CHINA, slot.captured) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddressTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddressTest.kt new file mode 100644 index 0000000000..cba2fa8fca --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetPrimaryAddressTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Test +import kotlin.test.assertEquals + +internal class GetPrimaryAddressTest { + + private val userId = UserIdTestData.userId + + private val observeUserAddresses = mockk() + + private val getPrimaryAddress = GetPrimaryAddress(observeUserAddresses) + + @Test + fun `returns primary user address when list contains more than one`() = runTest { + // Given + val addresses = listOf(UserAddressSample.AliasAddress, UserAddressSample.PrimaryAddress) + every { observeUserAddresses.invoke(userId) } returns flowOf(addresses) + + // When + val actual = getPrimaryAddress(userId) + + // Then + assertEquals(UserAddressSample.PrimaryAddress.right(), actual) + } + + @Test + fun `returns no cached data error when list is empty`() = runTest { + // Given + val addresses = emptyList() + every { observeUserAddresses.invoke(userId) } returns flowOf(addresses) + + // When + val actual = getPrimaryAddress(userId) + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperationTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperationTest.kt new file mode 100644 index 0000000000..d3f2c7aa5e --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/GetUndoableOperationTest.kt @@ -0,0 +1,34 @@ +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class GetUndoableOperationTest { + + private val undoableOperationRepository = mockk() + + private val getUndoableOperation = GetUndoableOperation(undoableOperationRepository) + + @Test + fun `get last undoable operation from the repository`() = runTest { + // Given + val lambda = { + println("logic to undo the operation") + Unit.right() + } + val expected = UndoableOperation.UndoMoveMessages(lambda) + coEvery { undoableOperationRepository.getLastOperation() } returns expected + + // When + val actual = getUndoableOperation() + + // Then + assertEquals(expected, actual) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUserTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUserTest.kt new file mode 100644 index 0000000000..305a55d004 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidMailUserTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.testdata.user.UserIdTestData +import ch.protonmail.android.testdata.user.UserTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class IsPaidMailUserTest { + + private val userId = UserIdTestData.userId + private val observeUser = mockk() + private val isPaidMailUser = IsPaidMailUser(observeUser) + + @Test + fun `returns true when the user is valid and has a paid mail plan`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(UserTestData.paidMailUser) + + // When + val actual = isPaidMailUser(userId) + + // Then + assertEquals(true.right(), actual) + } + + @Test + fun `returns false when user is valid and is not paid`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(UserTestData.freeUser) + + // When + val actual = isPaidMailUser(userId) + + // Then + assertEquals(false.right(), actual) + } + + @Test + fun `returns false when user is valid and is a paid non-mail user`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(UserTestData.paidUser) + + // When + val actual = isPaidMailUser(userId) + + // Then + assertEquals(false.right(), actual) + } + + @Test + fun `returns error when user is not valid`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(null) + + // When + val actual = isPaidMailUser(userId) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUserTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUserTest.kt new file mode 100644 index 0000000000..637f8e884a --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/IsPaidUserTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.testdata.user.UserIdTestData +import ch.protonmail.android.testdata.user.UserTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class IsPaidUserTest { + + private val userId = UserIdTestData.userId + + private val observeUser = mockk() + + private val isPaidUser = IsPaidUser(observeUser) + + @Test + fun `returns true when the user is valid and has any paid plan`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(UserTestData.paidUser) + + // When + val actual = isPaidUser(userId) + + // Then + assertEquals(true.right(), actual) + } + + @Test + fun `returns false when user is valid and is not paid`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(UserTestData.freeUser) + + // When + val actual = isPaidUser(userId) + + // Then + assertEquals(false.right(), actual) + } + + @Test + fun `returns error when user is not valid`() = runTest { + // Given + every { observeUser.invoke(userId) } returns flowOf(null) + + // When + val actual = isPaidUser(userId) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeatureTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeatureTest.kt new file mode 100644 index 0000000000..cf7ea79155 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveMailFeatureTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import ch.protonmail.android.mailcommon.domain.MailFeatureDefaults +import ch.protonmail.android.mailcommon.domain.MailFeatureId +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureFlag +import me.proton.core.featureflag.domain.entity.Scope +import org.junit.Test +import kotlin.test.assertEquals + +class ObserveMailFeatureTest { + + private val featureFlagManagerMock: FeatureFlagManager = mockk() + + @Test + fun `should return the requested feature flag if available`() = runTest { + // Given + val observeMailFeature = observeMailFeatureWithDefaults(emptyMap()) + val requestedFeature = MailFeatureId.ConversationMode + val expectedFeatureFlag = FeatureFlag( + userId = UserIdSample.Primary, + featureId = requestedFeature.id, + value = true, + scope = Scope.User, + defaultValue = true + ) + every { + featureFlagManagerMock.observe(UserIdSample.Primary, requestedFeature.id) + } returns flowOf(expectedFeatureFlag) + + // When + val actualFeatureFlag = observeMailFeature(UserIdSample.Primary, requestedFeature).first() + + // Then + assertEquals(expectedFeatureFlag, actualFeatureFlag) + } + + @Test + fun `should return the default feature flag if failed to retrieve`() = runTest { + // Given + val requestedFeature = MailFeatureId.ConversationMode + val defaultsMap = mapOf(requestedFeature to true) + val observeMailFeature = observeMailFeatureWithDefaults(defaultsMap) + val expectedFeatureFlag = FeatureFlag( + userId = UserIdSample.Primary, + featureId = requestedFeature.id, + value = true, + scope = Scope.Unknown, + defaultValue = true + ) + every { + featureFlagManagerMock.observe(UserIdSample.Primary, requestedFeature.id) + } returns flowOf(null) + + // When + val actualFeatureFlag = observeMailFeature(UserIdSample.Primary, requestedFeature).first() + + // Then + assertEquals(expectedFeatureFlag, actualFeatureFlag) + } + + private fun observeMailFeatureWithDefaults(defaultsMap: Map) = + ObserveMailFeature(featureFlagManagerMock, MailFeatureDefaults(defaultsMap)) +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserTest.kt new file mode 100644 index 0000000000..a965158fb7 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObservePrimaryUserTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import java.io.IOException +import app.cash.turbine.test +import ch.protonmail.android.testdata.user.UserIdTestData +import ch.protonmail.android.testdata.user.UserTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import me.proton.core.accountmanager.domain.AccountManager +import me.proton.core.domain.entity.UserId +import me.proton.core.test.kotlin.assertIs +import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.entity.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class ObservePrimaryUserTest { + + private val userIdFlow = MutableSharedFlow() + private val accountManager = mockk { + every { this@mockk.getPrimaryUserId() } returns userIdFlow + } + + private val userFlow = MutableSharedFlow() + private val userManager = mockk { + every { this@mockk.observeUser(UserIdTestData.userId) } returns userFlow + } + + private lateinit var observeUser: ObservePrimaryUser + + @Before + fun setUp() { + observeUser = ObservePrimaryUser( + accountManager, + userManager + ) + } + + @Test + fun `returns user when user manager returns valid user`() = runTest { + observeUser.invoke().test { + // Given + primaryUserIdIs(UserIdTestData.userId) + + // When + userManagerSuccessfullyReturns(UserTestData.Primary) + + // Then + val actual = awaitItem() + assertEquals(UserTestData.Primary, actual) + } + } + + @Test + fun `returns exception when user manager returns an error`() = runTest { + observeUser.invoke().test { + // Given + every { userManager.observeUser(UserIdTestData.userId) } throws IOException("Test") + + // When + primaryUserIdIs(UserIdTestData.userId) + + // Then + assertIs(awaitError()) + } + } + + @Test + fun `returns null when there is no valid userId`() = runTest { + observeUser.invoke().test { + // Given + primaryUserIdIs(null) + + // Then + assertNull(awaitItem()) + } + } + + private suspend fun primaryUserIdIs(userId: UserId?) { + userIdFlow.emit(userId) + } + + private suspend fun userManagerSuccessfullyReturns(user: User) { + userFlow.emit(user) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddressesTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddressesTest.kt new file mode 100644 index 0000000000..f7833c3a59 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserAddressesTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import me.proton.core.user.domain.UserManager +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Test +import kotlin.test.assertEquals + +internal class ObserveUserAddressesTest { + + private val userId = UserIdTestData.userId + + private val addressesFlow = MutableSharedFlow>() + private val userManager = mockk { + every { this@mockk.observeAddresses(userId) } returns addressesFlow + } + + private val observeUserAddresses = ObserveUserAddresses(userManager) + + @Test + fun `returns user addresses when user manager returns addresses`() = runTest { + observeUserAddresses.invoke(userId).test { + // Given + val addresses = listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) + userManagerSuccessfullyReturns(addresses) + + // Then + assertEquals(addresses, awaitItem()) + } + } + + private suspend fun userManagerSuccessfullyReturns(addresses: List) { + addressesFlow.emit(addresses) + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserTest.kt new file mode 100644 index 0000000000..b50deb4986 --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/ObserveUserTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.usecase + +import app.cash.turbine.test +import ch.protonmail.android.testdata.user.UserIdTestData.userId +import ch.protonmail.android.testdata.user.UserTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.user.domain.UserManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class ObserveUserTest { + + private val userManager: UserManager = mockk { + every { observeUser(userId) } returns flowOf(UserTestData.Primary) + } + private val observeUser = ObserveUser(userManager = userManager) + + @Test + fun `return correct user`() = runTest { + // when + observeUser(userId).test { + + // then + val expected = UserTestData.Primary + assertEquals(expected, awaitItem()) + awaitComplete() + } + } + + @Test + fun `returns null on error`() = runTest { + // given + every { userManager.observeUser(userId) } returns flowOf(null) + + // when + observeUser(userId).test { + + // then + assertNull(awaitItem()) + awaitComplete() + } + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperationTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperationTest.kt new file mode 100644 index 0000000000..bd1dbdd63a --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/usecase/RegisterUndoableOperationTest.kt @@ -0,0 +1,36 @@ +package ch.protonmail.android.mailcommon.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.UndoableOperation +import ch.protonmail.android.mailcommon.domain.repository.UndoableOperationRepository +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RegisterUndoableOperationTest { + + private val undoableOperationRepository = mockk() + + private val registerUndoableOperation = RegisterUndoableOperation(undoableOperationRepository) + + @Test + fun `registers undoable operation with the repository`() = runTest { + // Given + val lambda = { + println("logic to undo the operation") + Unit.right() + } + val operation = UndoableOperation.UndoMoveMessages(lambda) + coEvery { undoableOperationRepository.storeOperation(operation) } just Runs + + // When + registerUndoableOperation(operation) + + // Then + coVerify { undoableOperationRepository.storeOperation(operation) } + } +} diff --git a/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/util/PreconditionsTest.kt b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/util/PreconditionsTest.kt new file mode 100644 index 0000000000..f6deb7470e --- /dev/null +++ b/mail-common/domain/src/test/kotlin/ch/protonmail/android/mailcommon/domain/util/PreconditionsTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.domain.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class PreconditionsTest { + + @Test + fun `require not blank throw exception when string is blank`() { + // given + val string = " " + val expectedMessage = "Required value was blank." + + // when + val exception = assertFails { + requireNotBlank(string) + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not blank throw exception when string is null`() { + // given + val string: String? = null + val expectedMessage = "Required value was null." + + // when + val exception = assertFails { + requireNotBlank(string) + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not blank throw exception with field name when string is blank`() { + // given + val string = " " + val expectedMessage = "Field abc was blank." + + // when + val exception = assertFails { + requireNotBlank(string, fieldName = "Field abc") + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not blank throw exception with field name when string is null`() { + // given + val string: String? = null + val expectedMessage = "Field abc was null." + + // when + val exception = assertFails { + requireNotBlank(string, fieldName = "Field abc") + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not empty throw exception when string is empty`() { + // given + val string = "" + val expectedMessage = "Required value was empty." + + // when + val exception = assertFails { + requireNotEmpty(string) + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not empty throw exception when string is null`() { + // given + val string: String? = null + val expectedMessage = "Required value was null." + + // when + val exception = assertFails { + requireNotEmpty(string) + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not empty throw exception with field name when string is empty`() { + // given + val string = "" + val expectedMessage = "Field abc was empty." + + // when + val exception = assertFails { + requireNotEmpty(string, fieldName = "Field abc") + } + + // then + assertEquals(expectedMessage, exception.message) + } + + @Test + fun `require not empty throw exception with field name when string is null`() { + // given + val string: String? = null + val expectedMessage = "Field abc was null." + + // when + val exception = assertFails { + requireNotEmpty(string, fieldName = "Field abc") + } + + // then + assertEquals(expectedMessage, exception.message) + } +} diff --git a/mail-common/presentation/build.gradle.kts b/mail-common/presentation/build.gradle.kts new file mode 100644 index 0000000000..710a46240a --- /dev/null +++ b/mail-common/presentation/build.gradle.kts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "ch.protonmail.android.mailcommon.presentation" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + testInstrumentationRunner = Config.testInstrumentationRunner + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + buildConfig = true + compose = true + } + + packaging { + resources.excludes.add("META-INF/licenses/**") + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + + implementation(libs.bundles.compose) + implementation(libs.bundles.module.presentation) + + implementation(project(":mail-common:domain")) + implementation(project(":uicomponents")) + + testImplementation(libs.bundles.test) + testImplementation(project(":test:utils")) + testImplementation(project(":test:test-data")) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/AdaptivePreviews.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/AdaptivePreviews.kt new file mode 100644 index 0000000000..4725ad9fd5 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/AdaptivePreviews.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +@Preview(device = Devices.PHONE, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "1. Theme - Light mode") +@Preview(device = Devices.PHONE, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "2. Theme - Night mode") +@Preview(device = "spec:width=360dp,height=1080dp,orientation=portrait", name = "3. Size - Narrow") +@Preview(device = "spec:width=411dp,height=891dp,orientation=landscape", name = "4. Orientation Landscape") +@Preview( + device = "spec:width=360dp,height=1080dp,orientation=portrait", + name = "Size - Narrow - 200% font scale", + fontScale = 2.0f +) +@Preview(device = Devices.FOLDABLE, name = "5. Foldable") +@Preview(device = Devices.TABLET, name = "6. Tablet") +annotation class AdaptivePreviews diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ContentDescription.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ContentDescription.kt new file mode 100644 index 0000000000..2aace9b800 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ContentDescription.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation + +val NO_CONTENT_DESCRIPTION: String? = null diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/Effect.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/Effect.kt new file mode 100644 index 0000000000..a1fb91c817 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/Effect.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * This is a container for single-use state. + * Use this when you don't want an event to be repeated, for example while emitting an error to the ViewModel + * + * You usually wanna consume this into a `LaunchedEffect` block + */ +class Effect private constructor(private var event: T?) { + + /** + * @return the [event] if not consumed, `null` otherwise + */ + fun consume(): T? = event + .also { event = null } + + override fun equals(other: Any?): Boolean = other is Effect<*> && this.event == other.event + + override fun hashCode(): Int = event?.hashCode() ?: 0 + + companion object { + + fun of(event: T) = Effect(event) + fun empty() = Effect(null) + } +} + +/** + * Executes a [LaunchedEffect] in the scope of [effect] + * @param block will be called only when there is an [Effect.event] to consume + */ +@Composable +inline fun ConsumableLaunchedEffect( + effect: Effect, + crossinline block: suspend CoroutineScope.(T) -> Unit +) { + LaunchedEffect(effect) { + effect.consume()?.let { event -> + block(event) + } + } +} + +/** + * Executes [block] in the scope of [effect] + * @param block will be called only when there is an [Effect.event] to consume, resolving the [TextUiModel] to a + * [String] + */ +@Composable +inline fun ConsumableTextEffect( + effect: Effect, + crossinline block: suspend CoroutineScope.(String) -> Unit +) { + val scope = rememberCoroutineScope() + effect.consume()?.let { textUiModel -> + val string = textUiModel.string() + scope.launch { block(string) } + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/Avatar.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/Avatar.kt new file mode 100644 index 0000000000..9722856ff5 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/Avatar.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.AvatarUiModel +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme + +@Composable +fun Avatar( + modifier: Modifier = Modifier, + avatarUiModel: AvatarUiModel, + onClick: () -> Unit = {}, + clickable: Boolean = true, + outerContainerSize: Dp = MailDimens.DefaultTouchTargetSize, + avatarSize: Dp = MailDimens.AvatarMinSize, + backgroundShape: Shape = ProtonTheme.shapes.medium +) { + Box( + modifier = modifier + .testTag(AvatarTestTags.AvatarRootItem) + .size(outerContainerSize) + .run { + if (clickable) { + clickable(onClick = onClick) + } else { + this // Return the current modifier unchanged if not clickable + } + }, + contentAlignment = Alignment.Center + ) { + when (avatarUiModel) { + is AvatarUiModel.DraftIcon -> + Box( + modifier = Modifier + .testTag(AvatarTestTags.AvatarDraft) + .sizeIn( + minWidth = avatarSize, + minHeight = avatarSize + ) + .border( + width = MailDimens.DefaultBorder, + color = ProtonTheme.colors.interactionWeakNorm, + shape = backgroundShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(ProtonDimens.SmallIconSize), + painter = painterResource(id = R.drawable.ic_proton_pencil), + contentDescription = NO_CONTENT_DESCRIPTION + ) + } + + is AvatarUiModel.ParticipantInitial -> + Box( + modifier = Modifier + .sizeIn( + minWidth = avatarSize, + minHeight = avatarSize + ) + .background( + color = ProtonTheme.colors.interactionWeakNorm, + shape = backgroundShape + ), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier + .testTag(AvatarTestTags.AvatarText), + textAlign = TextAlign.Center, + text = avatarUiModel.value + ) + } + + is AvatarUiModel.SelectionMode -> + Box( + modifier = Modifier + .sizeIn( + minWidth = avatarSize, + minHeight = avatarSize + ) + .border( + width = MailDimens.AvatarBorderLine, + color = ProtonTheme.colors.interactionNorm, + shape = backgroundShape + ) + .background( + color = when (avatarUiModel.selected) { + true -> ProtonTheme.colors.interactionNorm + false -> ProtonTheme.colors.backgroundSecondary + }, + shape = backgroundShape + ) + .testTag(AvatarTestTags.AvatarSelectionMode) + .semantics { selected = avatarUiModel.selected }, + contentAlignment = Alignment.Center + ) { + if (avatarUiModel.selected) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_proton_checkmark), + tint = Color.White, + contentDescription = NO_CONTENT_DESCRIPTION, + modifier = Modifier.size(MailDimens.AvatarCheckmarkSize) + ) + } + } + } + } +} + +object AvatarTestTags { + + const val AvatarRootItem = "AvatarRootItem" + const val AvatarText = "AvatarText" + const val AvatarDraft = "AvatarDraft" + const val AvatarSelectionMode = "AvatarSelectionMode" +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/DpToPx.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/DpToPx.kt new file mode 100644 index 0000000000..79b271962a --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/DpToPx.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() } diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/FocusableForm.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/FocusableForm.kt new file mode 100644 index 0000000000..cd3342bc32 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/FocusableForm.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged + +/** + * Represents a set of focusable fields such that: + * - the focused field retains focus when the configuration change happens, + * - the focused field is brought into the view only after the IME is visible. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FocusableForm( + fieldList: List, + initialFocus: FocusedField, + onFocusedField: (FocusedField) -> Unit = {}, + content: @Composable FocusableFormScope.(Map) -> Unit +) { + var focusedField by rememberSaveable(inputs = emptyArray()) { mutableStateOf(initialFocus) } + val focusRequesters: Map = fieldList.associateWith { FocusRequester() } + val bringIntoViewRequesters: Map = + fieldList.associateWith { BringIntoViewRequester() } + val isKeyboardVisible by ch.protonmail.android.uicomponents.keyboardVisibilityAsState() + val onFieldFocused: (FocusedField) -> Unit = { + focusedField = it + onFocusedField(it) + } + + FocusableFormScope(focusRequesters, bringIntoViewRequesters, onFieldFocused).content(focusRequesters) + + LaunchedEffect(Unit) { + if (focusedField != initialFocus) { + focusRequesters[focusedField]?.requestFocus() + } else { + focusRequesters[initialFocus]?.requestFocus() + } + } + + // This is a workaround as the keyboard needs to be fully visible before the composable can be brought into + // the view, otherwise the bringIntoView() call has no effect. + // See https://kotlinlang.slack.com/archives/CJLTWPH7S/p1683542940483379 for more context. + LaunchedEffect(isKeyboardVisible) { + bringIntoViewRequesters[focusedField]?.bringIntoView() + } +} + +class FocusableFormScope @OptIn(ExperimentalFoundationApi::class) constructor( + private val focusRequesters: Map, + private val bringIntoViewRequesters: Map, + private val onFieldFocused: (focusedField: FocusedField) -> Unit +) { + + @OptIn(ExperimentalFoundationApi::class) + @Stable + fun Modifier.retainFieldFocusOnConfigurationChange(fieldType: FocusedField): Modifier { + val focusRequester = focusRequesters[fieldType] + val bringIntoViewRequester = bringIntoViewRequesters[fieldType] + return if (focusRequester != null && bringIntoViewRequester != null) { + focusRequester(focusRequester).bringIntoViewRequester(bringIntoViewRequester) + } else { + this + }.onFocusChanged { + if (it.hasFocus || it.isFocused) onFieldFocused(fieldType) + } + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/HyperlinkText.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/HyperlinkText.kt new file mode 100644 index 0000000000..164c01634f --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/HyperlinkText.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import android.text.method.LinkMovementMethod +import android.util.TypedValue +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun HyperlinkText( + modifier: Modifier = Modifier, + @StringRes textResource: Int, + textStyle: TextStyle = TextStyle.Default, + linkTextColor: Color = Color.Blue +) { + val resolver = LocalFontFamilyResolver.current + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + setLinkTextColor(linkTextColor.toArgb()) + setTextColor(textStyle.color.toArgb()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, textStyle.fontSize.value) + letterSpacing = textStyle.letterSpacing.value + gravity = android.view.Gravity.CENTER_HORIZONTAL + typeface = resolver.resolve(textStyle.fontFamily).value as android.graphics.Typeface + movementMethod = LinkMovementMethod.getInstance() + setText(textResource) + } + } + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/MailDimens.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/MailDimens.kt new file mode 100644 index 0000000000..1a718802dc --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/MailDimens.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object MailDimens { + + val ThinBorder = 0.5.dp + val DefaultBorder = 1.dp + val AvatarBorderLine = 1.5.dp + val OnboardingUpsellBestValueBorder = 2.dp + + val TinySpacing = 2.dp + + val SeparatorHeight = 1.dp + + val DefaultTouchTargetSize = 32.dp + + val AvatarMinSize = 28.dp + val AvatarCheckmarkSize = 20.dp + + val ProgressDefaultSize = 24.dp + val ProgressStrokeWidth = 2.dp + + val TinyIcon = 12.dp + + val ExtraLargeSpacing = 48.dp + + val ErrorIconBoxSize = 80.dp + + val ConversationMessageCollapseBarHeight = 16.dp + + val IconWeakRoundBackgroundRadius = 28.dp + + val ListItemCircleFilledSize = 16.dp + val ListItemCircleFilledPadding = 20.dp + + val TextFieldSingleLineSize = 80.dp + val TextFieldMultiLineSize = 128.dp + + val ExtraSmallNegativeOffset = (-4).dp + + object ColorPicker { + + val CircleBoxSize = 56.dp + val CircleSize = 20.dp + val SelectedCircleSize = 40.dp + val SelectedCircleBorderSize = 2.dp + val SelectedCircleInternalMargin = 10.dp + } + + val ScrollableFormBottomButtonSpacing = 60.dp + + const val ActionButtonShapeRadius = 100 + + val SingleLineTopAppBarHeight = 56.dp + val SubjectHeaderMinHeight = 56.dp + val MinOffsetForSubjectAlphaChange = 48.dp + + val pagerDotsCircleSize = 8.dp + val onboardingBottomButtonHeight = 48.dp + val OnboardingCloseButtonToolbarHeight = 48.dp + const val OnboardingIllustrationWeight = 0.5f + + object AutoLockPinScreen { + + val SpacerSize = 48.dp + val PinDotsGridHeight = 64.dp + val KeyboardButtonBoxSize = 84.dp + val DigitTextSize = 20.sp + val BottomButtonSize = 24.dp + } + + val ContactAvatarSize = 80.dp + val ContactAvatarCornerRadius = 30.dp + + val ContactActionSize = 52.dp + val ContactActionCornerRadius = 16.dp + + val ContactGroupLabelPaddingHorizontal = 6.dp + val ContactGroupLabelPaddingVertical = 2.dp + val ContactGroupLabelCornerRadius = 6.dp + + val ContactFormTypedFieldPaddingEnd = 56.dp + + val PickerDialogItemVerticalPadding = 12.dp + + val NotificationDotSize = 8.dp + + val ProtonCalendarIconSize = 40.dp + + object ColoredRadioButton { + val CircleSize = 15.dp + val SelectedCircleSize = 24.dp + val SelectedCircleBorderSize = 2.dp + val SelectedCircleInternalMargin = 4.dp + } + + val DialogCardRadius = 16.dp + + object ContactActions { + val AvatarSize = 40.dp + } + + val NarrowScreenWidth = 360.dp + + val PlanSwitcherHeight = 68.dp + val OnboardingUpsellButtonHeight = 48.dp + + val PostSubscriptionCloseButtonRippleRadius = 16.dp +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/OfficialBadge.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/OfficialBadge.kt new file mode 100644 index 0000000000..c4d306383c --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/OfficialBadge.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import ch.protonmail.android.mailcommon.presentation.R +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.overlineStrongNorm + +@Composable +fun OfficialBadge(modifier: Modifier = Modifier) { + Text( + modifier = modifier + .testTag(OfficialBadgeTestTags.Item) + .padding(start = ProtonDimens.ExtraSmallSpacing) + .background(color = ProtonTheme.colors.backgroundSecondary, shape = ProtonTheme.shapes.medium) + .padding(horizontal = ProtonDimens.ExtraSmallSpacing, vertical = MailDimens.TinySpacing), + text = stringResource(id = R.string.auth_badge_official), + maxLines = 1, + style = ProtonTheme.typography.overlineStrongNorm.copy(color = ProtonTheme.colors.textAccent) + ) +} + +object OfficialBadgeTestTags { + + const val Item = "OfficialBadge" +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PickerDialog.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PickerDialog.kt new file mode 100644 index 0000000000..25858507b9 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PickerDialog.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.material3.Card +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm +import me.proton.core.compose.theme.defaultStrongUnspecified +import me.proton.core.compose.theme.headlineNorm + +@Composable +fun PickerDialog( + modifier: Modifier = Modifier, + title: String, + selectedValue: TextUiModel, + values: List, + onDismissRequest: () -> Unit, + onValueSelected: (TextUiModel) -> Unit +) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card( + shape = RoundedCornerShape(MailDimens.DialogCardRadius), + modifier = modifier + .padding(ProtonDimens.DefaultSpacing) + .fillMaxWidth() + ) { + ConstraintLayout( + modifier = Modifier + .background(ProtonTheme.colors.backgroundNorm) + ) { + val (header, cancelButton, typeList) = createRefs() + + Text( + text = title, + style = ProtonTheme.typography.headlineNorm, + modifier = Modifier + .constrainAs(header) { + top.linkTo(parent.top, margin = ProtonDimens.MediumSpacing) + start.linkTo(parent.start, margin = ProtonDimens.MediumSpacing) + } + ) + TextButton( + onClick = { onDismissRequest() }, + modifier = Modifier + .constrainAs(cancelButton) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + ) { + Text( + text = stringResource(R.string.picker_dialog_cancel), + style = ProtonTheme.typography.defaultStrongUnspecified, + color = ProtonTheme.colors.brandNorm + ) + } + LazyColumn( + modifier = Modifier + .constrainAs(typeList) { + top.linkTo(header.bottom, margin = ProtonDimens.DefaultSpacing) + bottom.linkTo(cancelButton.top, margin = ProtonDimens.SmallSpacing) + start.linkTo(parent.start) + end.linkTo(parent.end) + height = Dimension.preferredWrapContent + } + ) { + items(values) { value -> + val isSelected = value == selectedValue + Row( + modifier = Modifier + .selectable( + selected = isSelected, + role = Role.RadioButton + ) { onValueSelected(value) } + .padding( + horizontal = ProtonDimens.DefaultSpacing, + vertical = MailDimens.PickerDialogItemVerticalPadding + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null + ) + Spacer(modifier = Modifier.width(ProtonDimens.DefaultSpacing)) + Text( + modifier = Modifier.weight(1f, fill = true), + text = value.string(), + style = ProtonTheme.typography.defaultNorm + ) + } + } + } + } + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +private fun TypePickerDialogPreview() { + PickerDialog( + title = "Title", + selectedValue = TextUiModel("Value 2"), + values = listOf(TextUiModel("Value 1"), TextUiModel("Value 2"), TextUiModel("Value 3")), + onDismissRequest = { }, + onValueSelected = { } + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PxToDp.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PxToDp.kt new file mode 100644 index 0000000000..713852fbd7 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/PxToDp.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity + +@Composable +fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } + +@Composable +fun Float.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/SmallNonClickableIcon.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/SmallNonClickableIcon.kt new file mode 100644 index 0000000000..5d9e8cb662 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/SmallNonClickableIcon.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.extension.tintColor +import me.proton.core.compose.theme.ProtonDimens + +@Composable +fun SmallNonClickableIcon( + @DrawableRes iconId: Int, + modifier: Modifier = Modifier, + tintId: Int = R.color.icon_weak +) { + SmallNonClickableIcon( + modifier = modifier, + iconId = iconId, + iconColor = colorResource(id = tintId) + ) +} + +@Composable +fun SmallNonClickableIcon( + @DrawableRes iconId: Int, + iconColor: Color, + modifier: Modifier = Modifier +) { + Icon( + modifier = modifier + .semantics { tintColor = iconColor } + .size(ProtonDimens.SmallIconSize), + painter = painterResource(id = iconId), + contentDescription = NO_CONTENT_DESCRIPTION, + tint = iconColor + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/UndoableOperationSnackbar.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/UndoableOperationSnackbar.kt new file mode 100644 index 0000000000..dbd4266cbf --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/compose/UndoableOperationSnackbar.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.compose + +import androidx.compose.material.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.ActionResult +import ch.protonmail.android.mailcommon.presentation.model.string +import ch.protonmail.android.mailcommon.presentation.viewmodel.UndoOperationViewModel +import kotlinx.coroutines.launch +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType +import timber.log.Timber + +@Composable +fun UndoableOperationSnackbar( + modifier: Modifier = Modifier, + snackbarHostState: ProtonSnackbarHostState, + actionEffect: Effect, + viewModel: UndoOperationViewModel = hiltViewModel() +) { + val coroutineScope = rememberCoroutineScope() + val undoActionLabel = stringResource(id = R.string.undo_button_label) + val state = viewModel.state.collectAsState() + + val undoSuccessMessage = stringResource(id = R.string.undo_success_message) + suspend fun showUndoSuccess() { + snackbarHostState.showSnackbar(ProtonSnackbarType.NORM, undoSuccessMessage) + } + + val undoFailureMessage = stringResource(id = R.string.undo_failure_message) + suspend fun showUndoFailure() { + snackbarHostState.showSnackbar(ProtonSnackbarType.ERROR, undoFailureMessage) + } + + ConsumableLaunchedEffect(effect = state.value.undoSucceeded) { showUndoSuccess() } + ConsumableLaunchedEffect(effect = state.value.undoFailed) { showUndoFailure() } + + actionEffect.consume()?.let { + val message = it.message.string() + + coroutineScope.launch { + if (it is ActionResult.UndoableActionResult) { + val result = snackbarHostState.showSnackbar(ProtonSnackbarType.NORM, message, undoActionLabel) + if (result == SnackbarResult.ActionPerformed) { + Timber.d("Undo action performed - $it") + viewModel.submitUndo() + } + } else { + snackbarHostState.showSnackbar(message = message, type = ProtonSnackbarType.NORM) + } + } + } + +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/ContextExtension.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/ContextExtension.kt new file mode 100644 index 0000000000..73fba16856 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/ContextExtension.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.extension + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.ContextCompat +import timber.log.Timber + +fun Context.copyTextToClipboard(label: String, text: String) { + val clipboardManager = ContextCompat.getSystemService(this, ClipboardManager::class.java) + val clip = ClipData.newPlainText(label, text) + + clipboardManager?.setPrimaryClip(clip) ?: Timber.w("Unable to copy data to clipboard") +} + +fun Context.openShareIntentForUri(uri: Uri, shareViaTitle: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, uri.toString()) + setType("text/plain") + } + + startActivity(Intent.createChooser(intent, shareViaTitle)) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/NavControllerExtension.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/NavControllerExtension.kt new file mode 100644 index 0000000000..0d43925472 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/NavControllerExtension.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.extension + +import androidx.navigation.NavController +import timber.log.Timber + +/** + * Navigates back by popping the backstack only if the current backstack entry is not the starting destination. + * This avoids navigating back to a blank screen if the user taps back/exit too quickly. + */ +fun NavController.navigateBack() { + val startDestination = graph.startDestinationId + + if (currentDestination?.id != startDestination) { + Timber.tag("NavController").d("Navigating back from: ${currentDestination?.route}") + popBackStack() + } else { + Timber.tag("NavController").d("Back navigation ignored, current location: ${currentDestination?.route}") + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/SemanticsPropertyReceiverExtension.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/SemanticsPropertyReceiverExtension.kt new file mode 100644 index 0000000000..d36f04d85b --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/extension/SemanticsPropertyReceiverExtension.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.extension + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import ch.protonmail.android.mailcommon.presentation.extension.CustomSemanticsPropertyKeys.isItemReadKey +import ch.protonmail.android.mailcommon.presentation.extension.CustomSemanticsPropertyKeys.tintColorKey + +/** + * Extension used to specify the tint [Color]? as a custom [SemanticsPropertyKey]. + */ +var SemanticsPropertyReceiver.tintColor by tintColorKey + +/** + * Extension used to specify whether the current item is in the unread or read state. + */ +var SemanticsPropertyReceiver.isItemRead by isItemReadKey + +private object CustomSemanticsPropertyKeys { + + val tintColorKey = SemanticsPropertyKey("TintColorKey") + val isItemReadKey = SemanticsPropertyKey("IsItemReadKey") +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapper.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapper.kt new file mode 100644 index 0000000000..7b591402a1 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapper.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel +import ch.protonmail.android.mailcommon.presentation.model.contentDescription +import ch.protonmail.android.mailcommon.presentation.model.description +import ch.protonmail.android.mailcommon.presentation.model.iconDrawable +import me.proton.core.domain.arch.Mapper +import javax.inject.Inject + +class ActionUiModelMapper @Inject constructor() : Mapper { + + fun toUiModel(action: Action) = ActionUiModel( + action = action, + icon = action.iconDrawable(), + description = action.description(), + contentDescription = action.contentDescription() + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapper.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapper.kt new file mode 100644 index 0000000000..2ba57027cc --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapper.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import androidx.compose.ui.graphics.Color +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import javax.inject.Inject + +class ColorMapper @Inject constructor() { + + fun toColor(string: String): Either { + @Suppress("MagicNumber") + with(string.substringAfter("#")) { + val (a, r, g, b) = when (length) { + 3 -> listOf(NoTransparency, substring(0, 1), substring(1, 2), substring(2, 3)) + 6 -> listOf(NoTransparency, substring(0, 2), substring(2, 4), substring(4, 6)) + 4 -> listOf(substring(0, 1), substring(1, 2), substring(2, 3), substring(3, 4)) + 8 -> listOf(substring(0, 2), substring(2, 4), substring(4, 6), substring(6, 8)) + else -> return string.left() + } + return Color( + alpha = a.toColorInt(), + red = r.toColorInt(), + green = g.toColorInt(), + blue = b.toColorInt() + ).right() + } + } + + private fun String.toColorInt() = toInt(radix = 16) + + companion object { + + const val NoTransparency = "FF" + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapper.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapper.kt new file mode 100644 index 0000000000..40dce94ced --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapper.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import ch.protonmail.android.mailcommon.domain.usecase.GetCurrentEpochTimeDuration +import ch.protonmail.android.mailcommon.presentation.R.string +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +class ExpirationTimeMapper @Inject constructor( + private val getCurrentEpochTimeDuration: GetCurrentEpochTimeDuration +) { + + fun toUiModel(epochTime: Duration): TextUiModel { + val timeDiff = epochTime - getCurrentEpochTimeDuration() + return when { + timeDiff <= 0.seconds -> TextUiModel(string.expiration_expired) + timeDiff < 1.hours -> TextUiModel(string.expiration_minutes_arg, timeDiff.inWholeMinutes) + timeDiff < 1.days -> TextUiModel(string.expiration_hours_arg, timeDiff.inWholeHours) + else -> TextUiModel(string.expiration_days_arg, timeDiff.inWholeDays) + } + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapper.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapper.kt new file mode 100644 index 0000000000..cef2caf944 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +object UnreadCountValueMapper { + + private const val CountThreshold = 9999 + private const val CountExceededCappedValue = "9999+" + + /** + * Returns the current value as String if the [count] is less than 9999, "9999+" otherwise. + */ + fun toCappedValue(count: Int): String = if (count <= CountThreshold) count.toString() else CountExceededCappedValue +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionResult.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionResult.kt new file mode 100644 index 0000000000..4d0e67ff1f --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +sealed class ActionResult( + open val message: TextUiModel +) { + + data class UndoableActionResult(override val message: TextUiModel) : ActionResult(message) + data class DefinitiveActionResult(override val message: TextUiModel) : ActionResult(message) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionUiModel.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionUiModel.kt new file mode 100644 index 0000000000..ac5bd18622 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ActionUiModel.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.R + +data class ActionUiModel( + val action: Action, + @DrawableRes val icon: Int = action.iconDrawable(), + val description: TextUiModel = action.description(), + val contentDescription: TextUiModel = action.contentDescription() +) + +@DrawableRes +@SuppressWarnings("ComplexMethod") +fun Action.iconDrawable() = when (this) { + Action.Reply -> R.drawable.ic_proton_reply + Action.ReplyAll -> R.drawable.ic_proton_reply_all + Action.Forward -> R.drawable.ic_proton_forward + Action.MarkRead -> R.drawable.ic_proton_envelope + Action.MarkUnread -> R.drawable.ic_proton_envelope_dot + Action.Star -> R.drawable.ic_proton_star + Action.Unstar -> R.drawable.ic_proton_star_slash + Action.Label -> R.drawable.ic_proton_tag + Action.Move -> R.drawable.ic_proton_folder_arrow_in + Action.Trash -> R.drawable.ic_proton_trash + Action.Delete -> R.drawable.ic_proton_trash_cross + Action.Archive -> R.drawable.ic_proton_archive_box + Action.Spam -> R.drawable.ic_proton_fire + Action.ViewInLightMode -> R.drawable.ic_proton_sun + Action.ViewInDarkMode -> R.drawable.ic_proton_moon + Action.Print -> R.drawable.ic_proton_printer + Action.ViewHeaders -> R.drawable.ic_proton_file_lines + Action.ViewHtml -> R.drawable.ic_proton_code + Action.ReportPhishing -> R.drawable.ic_proton_hook + Action.Remind -> R.drawable.ic_proton_clock + Action.SavePdf -> R.drawable.ic_proton_arrow_down_line + Action.SenderEmails -> R.drawable.ic_proton_envelope + Action.SaveAttachments -> R.drawable.ic_proton_arrow_down_to_square + Action.More -> R.drawable.ic_proton_three_dots_horizontal + Action.OpenCustomizeToolbar -> R.drawable.ic_proton_magic_proton_wand +} + +@get:StringRes +val Action.contentDescriptionRes: Int + get() = when (this) { + Action.Reply -> R.string.action_reply_content_description + Action.ReplyAll -> R.string.action_reply_all_content_description + Action.Forward -> R.string.action_forward_content_description + Action.MarkRead -> R.string.action_mark_read_content_description + Action.MarkUnread -> R.string.action_mark_unread_content_description + Action.Star -> R.string.action_star_content_description + Action.Unstar -> R.string.action_unstar_content_description + Action.Label -> R.string.action_label_content_description + Action.Move -> R.string.action_move_content_description + Action.Trash -> R.string.action_trash_content_description + Action.Delete -> R.string.action_delete_content_description + Action.Archive -> R.string.action_archive_content_description + Action.Spam -> R.string.action_spam_content_description + Action.ViewInLightMode -> R.string.action_view_in_light_mode_content_description + Action.ViewInDarkMode -> R.string.action_view_in_dark_mode_content_description + Action.Print -> R.string.action_print_content_description + Action.ViewHeaders -> R.string.action_view_headers_content_description + Action.ViewHtml -> R.string.action_view_html_content_description + Action.ReportPhishing -> R.string.action_report_phishing_content_description + Action.Remind -> R.string.action_remind_content_description + Action.SavePdf -> R.string.action_save_pdf_content_description + Action.SenderEmails -> R.string.action_sender_emails_content_description + Action.SaveAttachments -> R.string.action_save_attachments_content_description + Action.More -> R.string.action_more_content_description + Action.OpenCustomizeToolbar -> R.string.action_open_customize_toolbar + } + +@SuppressWarnings("ComplexMethod") +fun Action.contentDescription() = TextUiModel(contentDescriptionRes) + +@get:StringRes +val Action.descriptionRes: Int + get() = when (this) { + Action.Reply -> R.string.action_reply_description + Action.ReplyAll -> R.string.action_reply_all_description + Action.Forward -> R.string.action_forward_description + Action.MarkRead -> R.string.action_mark_read_description + Action.MarkUnread -> R.string.action_mark_unread_description + Action.Star -> R.string.action_star_description + Action.Unstar -> R.string.action_unstar_description + Action.Label -> R.string.action_label_description + Action.Move -> R.string.action_move_description + Action.Trash -> R.string.action_trash_description + Action.Delete -> R.string.action_delete_description + Action.Archive -> R.string.action_archive_description + Action.Spam -> R.string.action_spam_description + Action.ViewInLightMode -> R.string.action_view_in_light_mode_description + Action.ViewInDarkMode -> R.string.action_view_in_dark_mode_description + Action.Print -> R.string.action_print_description + Action.ViewHeaders -> R.string.action_view_headers_description + Action.ViewHtml -> R.string.action_view_html_description + Action.ReportPhishing -> R.string.action_report_phishing_description + Action.Remind -> R.string.action_remind_description + Action.SavePdf -> R.string.action_save_pdf_description + Action.SenderEmails -> R.string.action_sender_emails_description + Action.SaveAttachments -> R.string.action_save_attachments_description + Action.More -> R.string.action_more_description + Action.OpenCustomizeToolbar -> R.string.action_open_customize_toolbar_description + } + +fun Action.description() = TextUiModel.TextRes(descriptionRes) diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/AvatarUiModel.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/AvatarUiModel.kt new file mode 100644 index 0000000000..d48b92ee30 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/AvatarUiModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class AvatarUiModel { + data class ParticipantInitial(val value: String) : AvatarUiModel() + data class SelectionMode(val selected: Boolean) : AvatarUiModel() + object DraftIcon : AvatarUiModel() +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/BottomBarState.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/BottomBarState.kt new file mode 100644 index 0000000000..aef88c7ebf --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/BottomBarState.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface BottomBarState { + + sealed interface Data : BottomBarState { + + val actions: ImmutableList + + data class Hidden(override val actions: ImmutableList) : Data + data class Shown(override val actions: ImmutableList) : Data + } + + object Loading : BottomBarState + + sealed interface Error : BottomBarState { + object FailedLoadingActions : Error + } +} + +sealed interface BottomBarEvent { + + data class ActionsData(val actionUiModels: ImmutableList) : BottomBarEvent + + data class ShowAndUpdateActionsData(val actionUiModels: ImmutableList) : BottomBarEvent + + object ShowBottomSheet : BottomBarEvent + object HideBottomSheet : BottomBarEvent + + object ErrorLoadingActions : BottomBarEvent +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ColorHexWithName.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ColorHexWithName.kt new file mode 100644 index 0000000000..d3f060610a --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/ColorHexWithName.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +data class ColorHexWithName( + val name: TextUiModel, + val colorHex: String +) diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/DialogState.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/DialogState.kt new file mode 100644 index 0000000000..2054b06ac4 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/DialogState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +sealed interface DialogState { + + object Hidden : DialogState + + data class Shown( + val title: TextUiModel, + val message: TextUiModel, + val dismissButtonText: TextUiModel, + val confirmButtonText: TextUiModel + ) : DialogState + +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModel.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModel.kt new file mode 100644 index 0000000000..1309f7d6bc --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource + +@Immutable +sealed interface TextUiModel { + + data class Text(val value: String) : TextUiModel + + data class TextRes(@StringRes val value: Int) : TextUiModel + + data class TextResWithArgs( + @StringRes val value: Int, + val formatArgs: List + ) : TextUiModel { + + override fun equals(other: Any?): Boolean = other is TextResWithArgs && + other.value == value && + other.formatArgs.joinToString() == formatArgs.joinToString() + + override fun hashCode(): Int = value + formatArgs.joinToString().hashCode() + } + + data class PluralisedText( + @PluralsRes val value: Int, + val count: Int + ) : TextUiModel +} + +fun TextUiModel(value: String): TextUiModel = TextUiModel.Text(value) + +fun TextUiModel(@StringRes value: Int): TextUiModel = TextUiModel.TextRes(value) + +fun TextUiModel(@StringRes value: Int, vararg formatArgs: Any): TextUiModel = + TextUiModel.TextResWithArgs(value, formatArgs.toList()) + +fun TextUiModel(@PluralsRes pluralsRes: Int, count: Int): TextUiModel = TextUiModel.PluralisedText(pluralsRes, count) + +@Composable +@ReadOnlyComposable +fun TextUiModel.string() = when (this) { + is TextUiModel.Text -> value + is TextUiModel.TextRes -> stringResource(value) + is TextUiModel.TextResWithArgs -> stringResource(value, *formatArgs.toTypedArray()) + is TextUiModel.PluralisedText -> pluralStringResource(value, count, count) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/previewdata/BottomActionBarPreviewData.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/previewdata/BottomActionBarPreviewData.kt new file mode 100644 index 0000000000..891c80fea9 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/previewdata/BottomActionBarPreviewData.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.previewdata + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.model.contentDescription +import ch.protonmail.android.mailcommon.presentation.model.description +import ch.protonmail.android.mailcommon.presentation.model.iconDrawable +import kotlinx.collections.immutable.toImmutableList + +object BottomActionBarsPreviewData { + + val Data = BottomBarState.Data.Shown( + listOf( + ActionUiModel( + Action.MarkUnread, + Action.MarkUnread.iconDrawable(), + Action.MarkUnread.description(), + Action.MarkUnread.contentDescription() + ), + ActionUiModel( + Action.Archive, + Action.Archive.iconDrawable(), + Action.Archive.description(), + Action.Archive.contentDescription() + ), + ActionUiModel( + Action.Trash, + Action.Trash.iconDrawable(), + Action.Trash.description(), + Action.Trash.contentDescription() + ), + ActionUiModel( + Action.Move, + Action.Move.iconDrawable(), + Action.Move.description(), + Action.Move.contentDescription() + ) + ).toImmutableList() + ) + + val Loading = BottomBarState.Loading + + val Error = BottomBarState.Error.FailedLoadingActions +} + +class BottomActionBarPreviewProvider : PreviewParameterProvider { + + override val values = sequenceOf( + BottomActionBarsPreviewData.Data, + BottomActionBarsPreviewData.Loading, + BottomActionBarsPreviewData.Error + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducer.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducer.kt new file mode 100644 index 0000000000..55e1cd6990 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducer.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.reducer + +import ch.protonmail.android.mailcommon.presentation.model.BottomBarEvent +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import javax.inject.Inject + +class BottomBarReducer @Inject constructor() { + + @SuppressWarnings("UnusedPrivateMember") + fun newStateFrom(currentState: BottomBarState, event: BottomBarEvent): BottomBarState { + return when (event) { + is BottomBarEvent.ActionsData -> currentState.toNewStateForActionData(event) + is BottomBarEvent.ShowAndUpdateActionsData -> BottomBarState.Data.Shown(event.actionUiModels) + is BottomBarEvent.HideBottomSheet -> currentState.toNewStateForHiding() + is BottomBarEvent.ShowBottomSheet -> currentState.toNewStateForShowing() + is BottomBarEvent.ErrorLoadingActions -> currentState.toNewStateForErrorLoading() + } + } + + private fun BottomBarState.toNewStateForActionData(operation: BottomBarEvent.ActionsData) = when (this) { + is BottomBarState.Data.Hidden -> BottomBarState.Data.Hidden(operation.actionUiModels) + is BottomBarState.Data.Shown -> BottomBarState.Data.Shown(operation.actionUiModels) + is BottomBarState.Error.FailedLoadingActions -> BottomBarState.Data.Hidden(operation.actionUiModels) + is BottomBarState.Loading -> BottomBarState.Data.Hidden(operation.actionUiModels) + } + + private fun BottomBarState.toNewStateForErrorLoading() = when (this) { + is BottomBarState.Data -> this + is BottomBarState.Error.FailedLoadingActions -> this + is BottomBarState.Loading -> BottomBarState.Error.FailedLoadingActions + } + + private fun BottomBarState.toNewStateForHiding() = when (this) { + is BottomBarState.Data.Shown -> BottomBarState.Data.Hidden(this.actions) + else -> this + } + + private fun BottomBarState.toNewStateForShowing() = when (this) { + is BottomBarState.Data.Hidden -> BottomBarState.Data.Shown(this.actions) + else -> this + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/ActionUiModelSample.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/ActionUiModelSample.kt new file mode 100644 index 0000000000..aa7c72c968 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/ActionUiModelSample.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.sample + +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel +import ch.protonmail.android.mailcommon.presentation.model.contentDescription +import ch.protonmail.android.mailcommon.presentation.model.description +import ch.protonmail.android.mailcommon.presentation.model.iconDrawable + +object ActionUiModelSample { + + val Reply: ActionUiModel = + build(Action.Reply) + + val ReplyAll: ActionUiModel = + build(Action.Reply) + + val Forward: ActionUiModel = + build(Action.Reply) + + val Archive: ActionUiModel = + build(Action.Archive) + + val MarkUnread: ActionUiModel = + build(Action.MarkUnread) + + val CustomizeToolbar: ActionUiModel = + build(Action.OpenCustomizeToolbar) + + val Trash: ActionUiModel = + build(Action.Trash) + + val ReportPhishing: ActionUiModel = + build(Action.ReportPhishing) + + fun build(action: Action) = ActionUiModel( + action = action, + icon = action.iconDrawable(), + description = action.description(), + contentDescription = action.contentDescription() + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/TextMessageSample.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/TextMessageSample.kt new file mode 100644 index 0000000000..29b463d148 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/sample/TextMessageSample.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.sample + +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel + +object TextMessageSample { + + val NoNetwork = TextUiModel("No network") + val NotLoggedIn = TextUiModel("Not logged in") + val UnknownError = TextUiModel("Unknown error") +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/DeviceCapabilitiesProvider.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/DeviceCapabilitiesProvider.kt new file mode 100644 index 0000000000..4c454a2f41 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/DeviceCapabilitiesProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.system + +import androidx.compose.runtime.staticCompositionLocalOf +import ch.protonmail.android.mailcommon.domain.system.DeviceCapabilities + +val LocalDeviceCapabilitiesProvider = staticCompositionLocalOf { DeviceCapabilities.Capabilities(true) } diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/NotificationProvider.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/NotificationProvider.kt new file mode 100644 index 0000000000..62ce97e1d7 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/system/NotificationProvider.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.system + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import ch.protonmail.android.mailcommon.presentation.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +typealias ChannelId = String + +@Singleton +class NotificationProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val notificationManager: NotificationManager +) { + + fun initNotificationChannels() { + // Attachments + createNotificationChannel( + context = context, + channelId = ATTACHMENT_CHANNEL_ID, + channelName = R.string.attachment_download_notification_channel_name, + channelDescription = R.string.attachment_download_notification_channel_description + ) + + // Email + createNotificationChannel( + context = context, + channelId = EMAIL_CHANNEL_ID, + channelName = R.string.email_notification_channel_name, + channelDescription = R.string.email_notification_channel_description + ) + + // New logins + createNotificationChannel( + context = context, + channelId = LOGIN_CHANNEL_ID, + channelName = R.string.login_notification_channel_name, + channelDescription = R.string.login_notification_channel_description, + importance = NotificationManager.IMPORTANCE_HIGH + ) + } + + fun provideNotificationChannel(channelId: ChannelId): NotificationChannel = + notificationManager.getNotificationChannel(channelId) + + fun provideNotification( + context: Context, + channel: NotificationChannel, + @StringRes title: Int + ): Notification { + return NotificationCompat.Builder(context, channel.id).apply { + setContentTitle(context.getString(title)) + setSmallIcon(R.drawable.ic_proton_brand_proton_mail) + setOngoing(true) + }.build() + } + + fun provideEmailNotificationBuilder( + context: Context, + contentTitle: String, + subText: String, + contentText: String, + group: String, + isGroupSummary: Boolean = false, + autoCancel: Boolean = false + ): NotificationCompat.Builder { + val channel = provideNotificationChannel(EMAIL_CHANNEL_ID) + return NotificationCompat.Builder(context, channel.id).apply { + setContentTitle(contentTitle) + setSmallIcon(R.drawable.ic_proton_brand_proton_mail) + setSubText(subText) + setContentText(contentText) + setGroup(group) + setGroupSummary(isGroupSummary) + setAutoCancel(autoCancel) + } + } + + fun provideLoginNotificationBuilder( + context: Context, + userAddress: String, + contentTitle: String, + contentText: String, + group: String, + isGroupSummary: Boolean = false, + autoCancel: Boolean = false + ): NotificationCompat.Builder { + val channel = provideNotificationChannel(LOGIN_CHANNEL_ID) + val style = NotificationCompat.BigTextStyle().run { + setSummaryText(userAddress) + bigText(contentText) + } + return NotificationCompat.Builder(context, channel.id).apply { + setStyle(style) + setContentTitle(contentTitle) + setSmallIcon(R.drawable.ic_proton_brand_proton_mail) + setContentText(contentText) + setGroup(group) + setGroupSummary(isGroupSummary) + setAutoCancel(autoCancel) + } + } + + private fun createNotificationChannel( + context: Context, + channelId: ChannelId, + @StringRes channelName: Int, + @StringRes channelDescription: Int, + importance: Int = NotificationManager.IMPORTANCE_DEFAULT + ) { + val channelNameString = context.getString(channelName) + val channelDescriptionString = context.getString(channelDescription) + val notificationChannel = NotificationChannel(channelId, channelNameString, importance).apply { + description = channelDescriptionString + } + notificationManager.createNotificationChannel(notificationChannel) + } + + companion object { + + const val ATTACHMENT_CHANNEL_ID: ChannelId = "attachment_channel_id" + const val EMAIL_CHANNEL_ID: ChannelId = "email_channel_id" + const val LOGIN_CHANNEL_ID: ChannelId = "login_channel_id" + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/AutoDeleteBanner.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/AutoDeleteBanner.kt new file mode 100644 index 0000000000..44c36bfcd3 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/AutoDeleteBanner.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.compose.MailDimens +import me.proton.core.compose.component.ProtonTextButton +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultSmallNorm + +@Composable +fun AutoDeleteBanner( + modifier: Modifier = Modifier, + uiModel: AutoDeleteBannerUiModel, + actions: AutoDeleteBannerActions +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + start = ProtonDimens.DefaultSpacing, + end = ProtonDimens.DefaultSpacing, + bottom = ProtonDimens.SmallSpacing + ProtonDimens.ExtraSmallSpacing + ) + .clickable(enabled = uiModel is AutoDeleteBannerUiModel.Upgrade, onClick = actions.onActionClick) + .border( + width = MailDimens.DefaultBorder, + color = ProtonTheme.colors.separatorNorm, + shape = ProtonTheme.shapes.medium + ) + .background( + color = ProtonTheme.colors.backgroundNorm, + shape = ProtonTheme.shapes.medium + ) + .padding(ProtonDimens.DefaultSpacing) + ) { + + val mainText = when (uiModel) { + is AutoDeleteBannerUiModel.Info -> R.string.auto_delete_banner_text_enabled_info + is AutoDeleteBannerUiModel.Upgrade -> R.string.auto_delete_banner_text_upgrade + is AutoDeleteBannerUiModel.Activate.Spam -> R.string.auto_delete_banner_text_activate_spam + is AutoDeleteBannerUiModel.Activate.Trash -> R.string.auto_delete_banner_text_activate_trash + } + + Row { + when (uiModel) { + is AutoDeleteBannerUiModel.Upgrade, is AutoDeleteBannerUiModel.Activate -> Icon( + painter = painterResource(id = R.drawable.ic_m_plus_rainbow), + contentDescription = null, + tint = Color.Unspecified + ) + + else -> Icon( + painter = painterResource(id = R.drawable.ic_proton_trash_clock), + contentDescription = null, + tint = ProtonTheme.colors.iconWeak + ) + } + Spacer(modifier = Modifier.width(ProtonDimens.SmallSpacing)) + Column { + Text( + text = stringResource(mainText), + style = ProtonTheme.typography.defaultSmallNorm + ) + if (uiModel is AutoDeleteBannerUiModel.Upgrade) { + Spacer(modifier = Modifier.height(ProtonDimens.SmallSpacing)) + Text( + text = stringResource(R.string.auto_delete_banner_action_upgrade_learn_more), + color = ProtonTheme.colors.textAccent + ) + } + } + } + + if (uiModel is AutoDeleteBannerUiModel.Activate) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = ProtonDimens.SmallSpacing), + horizontalArrangement = Arrangement.spacedBy(ProtonDimens.DefaultSpacing) + ) { + ProtonTextButton( + modifier = Modifier.weight(1f), + onClick = actions.onConfirmClick, + colors = ButtonDefaults.buttonColors( + backgroundColor = ProtonTheme.colors.brandNorm + ) + ) { + Text( + text = stringResource(id = R.string.auto_delete_banner_button_activate_confirm), + color = ProtonTheme.colors.textInverted + ) + } + ProtonTextButton( + modifier = Modifier.weight(1f), + onClick = actions.onDismissClick, + colors = ButtonDefaults.buttonColors( + backgroundColor = ProtonTheme.colors.backgroundSecondary + ) + ) { + Text( + text = stringResource(id = R.string.auto_delete_banner_button_activate_dismiss), + color = ProtonTheme.colors.textNorm + ) + } + } + } + } +} + +sealed interface AutoDeleteBannerUiModel { + + data object Upgrade : AutoDeleteBannerUiModel + data object Info : AutoDeleteBannerUiModel + + sealed interface Activate : AutoDeleteBannerUiModel { + data object Trash : Activate + data object Spam : Activate + } +} + +data class AutoDeleteBannerActions( + val onActionClick: () -> Unit, + val onConfirmClick: () -> Unit, + val onDismissClick: () -> Unit +) { + + companion object { + + val Empty = AutoDeleteBannerActions( + {}, + {}, + {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AutoDeleteBannerPreviewUpgrade() { + ProtonTheme { + AutoDeleteBanner( + modifier = Modifier, + uiModel = AutoDeleteBannerUiModel.Upgrade, + actions = AutoDeleteBannerActions.Empty + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AutoDeleteBannerPreviewActivateSpam() { + ProtonTheme { + AutoDeleteBanner( + modifier = Modifier, + uiModel = AutoDeleteBannerUiModel.Activate.Spam, + actions = AutoDeleteBannerActions.Empty + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AutoDeleteBannerPreviewActivateTrash() { + ProtonTheme { + AutoDeleteBanner( + modifier = Modifier, + uiModel = AutoDeleteBannerUiModel.Activate.Trash, + actions = AutoDeleteBannerActions.Empty + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun AutoDeleteBannerPreviewInfo() { + ProtonTheme { + AutoDeleteBanner( + modifier = Modifier, + uiModel = AutoDeleteBannerUiModel.Info, + actions = AutoDeleteBannerActions.Empty + ) + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/BottomActionBar.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/BottomActionBar.kt new file mode 100644 index 0000000000..aa1ca4f9ec --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/BottomActionBar.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.AdaptivePreviews +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.compose.MailDimens +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import ch.protonmail.android.mailcommon.presentation.previewdata.BottomActionBarPreviewProvider +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.ProtonTypography +import me.proton.core.compose.theme.default + +@Composable +fun BottomActionBar( + state: BottomBarState, + viewActionCallbacks: BottomActionBar.Actions, + modifier: Modifier = Modifier +) { + if (state is BottomBarState.Data.Hidden) return + Column( + modifier = modifier.background(ProtonTheme.colors.backgroundNorm) + ) { + Divider(color = ProtonTheme.colors.separatorNorm, thickness = MailDimens.SeparatorHeight) + + Row( + modifier = Modifier + .testTag(BottomActionBarTestTags.RootItem) + .clickable( + enabled = false, + onClick = { + // this is needed otherwise the click event is passed down the view hierarchy + } + ) + .fillMaxWidth() + .padding(horizontal = 0.dp, vertical = ProtonDimens.DefaultSpacing), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + when (state) { + is BottomBarState.Loading -> { + ProtonCenteredProgress(modifier = Modifier.size(MailDimens.ProgressDefaultSize)) + } + + is BottomBarState.Error -> Text( + text = stringResource(id = R.string.common_error_loading_actions), + style = ProtonTypography.Default.default + ) + + is BottomBarState.Data.Shown -> { + state.actions.forEachIndexed { index, uiModel -> + if (index.exceedsMaxActionsShowed()) { + return@forEachIndexed + } + BottomBarIcon( + modifier = Modifier.testTag("${BottomActionBarTestTags.Button}$index"), + iconId = uiModel.icon, + description = uiModel.contentDescription, + onClick = callbackForAction(uiModel.action, viewActionCallbacks) + ) + } + } + + else -> { + // no-op, Hidden state is handled at the beginning + } + } + } + } +} + +@SuppressWarnings("ComplexMethod") +fun callbackForAction(action: Action, viewActionCallbacks: BottomActionBar.Actions) = when (action) { + Action.MarkRead -> viewActionCallbacks.onMarkRead + Action.MarkUnread -> viewActionCallbacks.onMarkUnread + Action.Star -> viewActionCallbacks.onStar + Action.Unstar -> viewActionCallbacks.onUnstar + Action.Label -> viewActionCallbacks.onLabel + Action.Move -> viewActionCallbacks.onMove + Action.Trash -> viewActionCallbacks.onTrash + Action.Delete -> viewActionCallbacks.onDelete + Action.Archive -> viewActionCallbacks.onArchive + Action.Spam -> viewActionCallbacks.onSpam + Action.ViewInLightMode -> viewActionCallbacks.onViewInLightMode + Action.ViewInDarkMode -> viewActionCallbacks.onViewInDarkMode + Action.Print -> viewActionCallbacks.onPrint + Action.ViewHeaders -> viewActionCallbacks.onViewHeaders + Action.ViewHtml -> viewActionCallbacks.onViewHtml + Action.ReportPhishing -> viewActionCallbacks.onReportPhishing + Action.Remind -> viewActionCallbacks.onRemind + Action.SavePdf -> viewActionCallbacks.onSavePdf + Action.SenderEmails -> viewActionCallbacks.onSenderEmail + Action.SaveAttachments -> viewActionCallbacks.onSaveAttachments + Action.More -> viewActionCallbacks.onMore + Action.Reply -> viewActionCallbacks.onReply + Action.ReplyAll -> viewActionCallbacks.onReplyAll + Action.Forward -> viewActionCallbacks.onForward + Action.OpenCustomizeToolbar -> viewActionCallbacks.onCustomizeToolbar +} + +@Composable +private fun Int.exceedsMaxActionsShowed() = this > BottomActionBar.MAX_ACTIONS_COUNT + +@Composable +private fun BottomBarIcon( + modifier: Modifier = Modifier, + @DrawableRes iconId: Int, + description: TextUiModel, + onClick: () -> Unit +) { + IconButton( + modifier = modifier.size(ProtonDimens.DefaultIconSize), + onClick = onClick + ) { + Icon( + modifier = Modifier, + painter = painterResource(id = iconId), + contentDescription = description.string(), + tint = ProtonTheme.colors.iconNorm + ) + } +} + +object BottomActionBar { + + internal const val MAX_ACTIONS_COUNT = 5 + + data class Actions( + val onMarkRead: () -> Unit, + val onMarkUnread: () -> Unit, + val onStar: () -> Unit, + val onUnstar: () -> Unit, + val onMove: () -> Unit, + val onLabel: () -> Unit, + val onTrash: () -> Unit, + val onDelete: () -> Unit, + val onArchive: () -> Unit, + val onReply: () -> Unit, + val onReplyAll: () -> Unit, + val onForward: () -> Unit, + val onSpam: () -> Unit, + val onViewInLightMode: () -> Unit, + val onViewInDarkMode: () -> Unit, + val onPrint: () -> Unit, + val onViewHeaders: () -> Unit, + val onViewHtml: () -> Unit, + val onReportPhishing: () -> Unit, + val onRemind: () -> Unit, + val onSavePdf: () -> Unit, + val onSenderEmail: () -> Unit, + val onSaveAttachments: () -> Unit, + val onMore: () -> Unit, + val onCustomizeToolbar: () -> Unit + ) { + + companion object { + + val Empty = Actions( + onMarkRead = {}, + onMarkUnread = {}, + onStar = {}, + onUnstar = {}, + onMove = {}, + onLabel = {}, + onTrash = {}, + onDelete = {}, + onArchive = {}, + onForward = {}, + onSpam = {}, + onReply = {}, + onReplyAll = {}, + onViewInLightMode = {}, + onViewInDarkMode = {}, + onPrint = {}, + onViewHeaders = {}, + onViewHtml = {}, + onReportPhishing = {}, + onRemind = {}, + onSavePdf = {}, + onSenderEmail = {}, + onSaveAttachments = {}, + onMore = {}, + onCustomizeToolbar = {} + ) + } + } + +} + +@Composable +@AdaptivePreviews +private fun BottomActionPreview(@PreviewParameter(BottomActionBarPreviewProvider::class) state: BottomBarState) { + ProtonTheme { + BottomActionBar(state = state, viewActionCallbacks = BottomActionBar.Actions.Empty) + } +} + +object BottomActionBarTestTags { + + const val RootItem = "BottomActionBarRootItem" + const val Button = "BottomActionBarIcon" +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/CommonTestTags.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/CommonTestTags.kt new file mode 100644 index 0000000000..0ffe57bc8e --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/CommonTestTags.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui + +object CommonTestTags { + + const val SnackbarHost = "SnackbarHost" + + const val SnackbarHostError = "SnackbarHostError" + const val SnackbarHostWarning = "SnackbarHostWarning" + const val SnackbarHostNormal = "SnackbarHostNormal" + const val SnackbarHostSuccess = "SnackbarHostSuccess" +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/MailDivider.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/MailDivider.kt new file mode 100644 index 0000000000..6c95e2d3d0 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/MailDivider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui + +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import ch.protonmail.android.mailcommon.presentation.compose.MailDimens +import me.proton.core.compose.theme.ProtonTheme + +@Composable +fun MailDivider(modifier: Modifier = Modifier) { + Divider( + modifier = modifier.testTag(MailDividerTestTags.HeaderDivider), + thickness = MailDimens.SeparatorHeight, + color = ProtonTheme.colors.separatorNorm + ) +} + +object MailDividerTestTags { + + const val HeaderDivider = "HeaderDivider" +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/AutoDeleteDialog.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/AutoDeleteDialog.kt new file mode 100644 index 0000000000..aa81603a7b --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/AutoDeleteDialog.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui.delete + +import androidx.compose.runtime.Composable +import ch.protonmail.android.mailcommon.presentation.model.string +import ch.protonmail.android.mailcommon.presentation.model.DialogState +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText + +@Composable +fun AutoDeleteDialog( + state: DialogState, + confirm: () -> Unit, + dismiss: () -> Unit +) { + if (state is DialogState.Shown) { + ProtonAlertDialog( + onDismissRequest = dismiss, + confirmButton = { + ProtonAlertDialogButton(state.confirmButtonText.string()) { + confirm() + } + }, + dismissButton = { + ProtonAlertDialogButton(state.dismissButtonText.string()) { + dismiss() + } + }, + title = state.title.string(), + text = { ProtonAlertDialogText(state.message.string()) } + ) + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialog.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialog.kt new file mode 100644 index 0000000000..7bfd5b5e1d --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialog.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui.delete + +import androidx.compose.runtime.Composable +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.string +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText + +@Composable +fun DeleteDialog( + state: DeleteDialogState, + confirm: () -> Unit, + dismiss: () -> Unit +) { + if (state is DeleteDialogState.Shown) { + ProtonAlertDialog( + onDismissRequest = dismiss, + confirmButton = { + ProtonAlertDialogButton(R.string.mailbox_action_delete_dialog_button_delete) { + confirm() + } + }, + dismissButton = { + ProtonAlertDialogButton(R.string.mailbox_action_delete_dialog_button_cancel) { + dismiss() + } + }, + title = state.title.string(), + text = { ProtonAlertDialogText(state.message.string()) } + ) + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialogState.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialogState.kt new file mode 100644 index 0000000000..6b09691c5f --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/delete/DeleteDialogState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui.delete + +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel + +sealed interface DeleteDialogState { + + object Hidden : DeleteDialogState + + data class Shown( + val title: TextUiModel, + val message: TextUiModel + ) : DeleteDialogState + +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltip.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltip.kt new file mode 100644 index 0000000000..452bccc534 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltip.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui.spotlight + +import android.content.res.Configuration +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import ch.protonmail.android.mailcommon.presentation.AdaptivePreviews +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import me.proton.core.compose.component.ProtonButton +import me.proton.core.compose.component.protonButtonColors +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme + +@Suppress("UseComposableActions") +@Composable +fun SpotlightTooltip( + modifier: Modifier = Modifier, + dialogState: SpotlightTooltipState, + ctaClick: () -> Unit, + dismiss: () -> Unit, + displayed: () -> Unit +) { + val state = dialogState as? SpotlightTooltipState.Shown ?: return + val orientation = LocalConfiguration.current.orientation + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + return + } + var dismissed by remember { mutableStateOf(false) } + if (dismissed) return + val model = state.model + + LaunchedEffect(key1 = Unit) { + displayed() + } + + Dialog( + onDismissRequest = dismiss, + properties = DialogProperties(usePlatformDefaultWidth = true) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + dismiss() + } + .padding( + bottom = ProtonDimens.SmallSpacing + ProtonDimens.DefaultIconWithPadding + ), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val bgColor = ProtonTheme.colors.backgroundNorm + Surface( + shape = RoundedCornerShape(ProtonDimens.LargeCornerRadius), + color = bgColor, + modifier = modifier + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { } + ) { + Column( + modifier = Modifier + .padding(ProtonDimens.DefaultSpacing) + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .verticalScroll( + rememberScrollState() + ) + ) { + Image( + modifier = Modifier.size(ProtonDimens.DefaultIconWithPadding), + painter = painterResource(id = R.drawable.ic_wand), + contentDescription = null + ) + Column(modifier = Modifier.weight(1f)) { + Text( + modifier = Modifier.padding(start = ProtonDimens.SmallSpacing), + text = model.title.string(), + style = ProtonTheme.typography.body2Medium, + color = ProtonTheme.colors.textNorm, + textAlign = TextAlign.Start + ) + Text( + modifier = Modifier + .padding(start = ProtonDimens.SmallSpacing) + .padding(top = ProtonDimens.SmallSpacing) + .fillMaxWidth(), + text = model.message.string(), + style = ProtonTheme.typography.body2Regular, + color = ProtonTheme.colors.textNorm, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start + ) + } + } + Spacer(Modifier.height(ProtonDimens.SmallSpacing)) + ProtonButton( + onClick = { + dismissed = true + dismiss() + ctaClick() + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = ButtonDefaults.MinHeight), + shape = ProtonTheme.shapes.medium, + border = null, + elevation = null, + colors = ButtonDefaults.protonButtonColors( + backgroundColor = ProtonTheme.colors.interactionWeakNorm + ), + contentPadding = ButtonDefaults.ContentPadding + ) { + Text(text = model.cta.string(), color = ProtonTheme.colors.textNorm) + } + } + } + Canvas( + modifier = Modifier + .width(ProtonDimens.LargeSpacing) + .height(ProtonDimens.DefaultSpacing) + ) { + val radius = 8f + val offset = -1f + drawPath( + Path().apply { + moveTo(0f, offset) + lineTo(size.width, offset) + lineTo(size.width / 2f + radius, size.height - radius) + quadraticTo( + size.width / 2f, size.height, + size.width / 2f - radius, size.height - radius + ) + close() + }, + color = bgColor + ) + } + } + } +} + +@AdaptivePreviews +@Composable +private fun SpotlightTooltipPreview() { + ProtonTheme { + SpotlightTooltip( + dialogState = SpotlightTooltipState.Shown( + SpotlightUiModel( + title = TextUiModel.Text("Customize toolbar"), + message = TextUiModel.Text( + "You can now choose and rearrange the actions in this toolbar" + ), + cta = TextUiModel.Text("Show me") + ) + ), + ctaClick = {}, + dismiss = {}, + displayed = {} + ) + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltipState.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltipState.kt new file mode 100644 index 0000000000..acd2162087 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/ui/spotlight/SpotlightTooltipState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.ui.spotlight + +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel + +sealed interface SpotlightTooltipState { + + data object Hidden : SpotlightTooltipState + + data class Shown( + val model: SpotlightUiModel + ) : SpotlightTooltipState +} + +data class SpotlightUiModel( + val title: TextUiModel, + val message: TextUiModel, + val cta: TextUiModel +) diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/DecodeByteArray.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/DecodeByteArray.kt new file mode 100644 index 0000000000..d851c44b21 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/DecodeByteArray.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import android.graphics.Bitmap +import android.graphics.BitmapFactory.decodeByteArray +import javax.inject.Inject + +class DecodeByteArray @Inject constructor() { + + operator fun invoke(byteArray: ByteArray): Bitmap? = decodeByteArray(byteArray, 0, byteArray.size) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTime.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTime.kt new file mode 100644 index 0000000000..2ce98e1e79 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTime.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import java.text.DateFormat +import java.util.Date +import ch.protonmail.android.mailcommon.domain.usecase.GetAppLocale +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import javax.inject.Inject +import kotlin.time.Duration + +class FormatExtendedTime @Inject constructor( + private val getAppLocale: GetAppLocale +) { + + operator fun invoke(duration: Duration): TextUiModel = TextUiModel.Text( + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, getAppLocale()) + .format(Date(duration.inWholeMilliseconds)) + ) +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatLocalDate.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatLocalDate.kt new file mode 100644 index 0000000000..24e3a413f0 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatLocalDate.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import ch.protonmail.android.mailcommon.domain.usecase.GetAppLocale +import javax.inject.Inject + +class FormatLocalDate @Inject constructor( + private val getAppLocale: GetAppLocale +) { + + operator fun invoke(date: LocalDate): String { + return date.format( + DateTimeFormatter.ofLocalizedDate( + FormatStyle.MEDIUM + ).withLocale( + getAppLocale() + ) + ) + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTime.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTime.kt new file mode 100644 index 0000000000..b7c53d4fc2 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTime.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import ch.protonmail.android.mailcommon.domain.usecase.GetAppLocale +import ch.protonmail.android.mailcommon.domain.usecase.GetLocalisedCalendar +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import javax.inject.Inject +import kotlin.time.Duration + +class FormatShortTime @Inject constructor( + private val getLocalisedCalendar: GetLocalisedCalendar, + private val getAppLocale: GetAppLocale +) { + + private val currentTime: Calendar + get() = getLocalisedCalendar() + + operator fun invoke(itemTime: Duration): TextUiModel { + if (itemTime.isToday()) { + return TextUiModel.Text(itemTime.toHourAndMinutes()) + } + if (itemTime.isYesterday()) { + return TextUiModel.TextRes(R.string.yesterday) + } + if (itemTime.isThisWeek()) { + return TextUiModel.Text(itemTime.toWeekDay()) + } + if (itemTime.isThisWeekAcrossNewYear()) { + return TextUiModel.Text(itemTime.toWeekDay()) + } + if (itemTime.isThisYear()) { + return TextUiModel.Text(itemTime.toFullDate()) + } + return TextUiModel.Text(itemTime.toFullDate()) + } + + private fun Duration.toFullDate() = DateFormat.getDateInstance(DateFormat.MEDIUM, getAppLocale()) + .format(Date(this.inWholeMilliseconds)) + + private fun Duration.toHourAndMinutes() = DateFormat.getTimeInstance(DateFormat.SHORT, getAppLocale()) + .format(Date(this.inWholeMilliseconds)) + + private fun Duration.toWeekDay() = SimpleDateFormat("EEEE", getAppLocale()) + .format(Date(this.inWholeMilliseconds)) + + private fun isYesterday(itemCalendar: Calendar) = isCurrentYear(itemCalendar) && + currentTime.get(Calendar.DAY_OF_YEAR) - itemCalendar.get(Calendar.DAY_OF_YEAR) == 1 || + isYesterdayAcrossYearChange(itemCalendar) + + private fun isToday(itemCalendar: Calendar) = isCurrentYear(itemCalendar) && + currentTime.get(Calendar.DAY_OF_YEAR) == itemCalendar.get(Calendar.DAY_OF_YEAR) + + private fun isCurrentWeek(itemCalendar: Calendar) = isCurrentYear(itemCalendar) && + currentTime.get(Calendar.WEEK_OF_YEAR) == itemCalendar.get(Calendar.WEEK_OF_YEAR) + + private fun isCurrentWeekAcrossNewYear(itemCalendar: Calendar) = isLastWeekOfTheYear(itemCalendar) && + currentTime.get(Calendar.WEEK_OF_YEAR) == itemCalendar.get(Calendar.WEEK_OF_YEAR) + + private fun isCurrentYear(itemCalendar: Calendar) = + currentTime.get(Calendar.YEAR) == itemCalendar.get(Calendar.YEAR) + + private fun isYesterdayAcrossYearChange(itemCalendar: Calendar) = isPreviousYear(itemCalendar) && + itemCalendar.get(Calendar.DAY_OF_YEAR) - currentTime.get(Calendar.DAY_OF_YEAR) == DAYS_IN_ONE_YEAR - 1 + + private fun isPreviousYear(itemCalendar: Calendar) = + currentTime.get(Calendar.YEAR) - itemCalendar.get(Calendar.YEAR) == 1 + + private fun isLastWeekOfTheYear(itemCalendar: Calendar) = + itemCalendar.weeksInWeekYear == currentTime.get(Calendar.WEEK_OF_YEAR) + + + private fun Duration.isToday() = isToday(toCalendar()) + private fun Duration.isYesterday() = isYesterday(toCalendar()) + private fun Duration.isThisWeek() = isCurrentWeek(toCalendar()) + private fun Duration.isThisWeekAcrossNewYear() = isCurrentWeekAcrossNewYear(toCalendar()) + private fun Duration.isThisYear() = isCurrentYear(toCalendar()) + + private fun Duration.toCalendar(): Calendar { + val itemCalendar = Calendar.getInstance(getAppLocale()) + itemCalendar.time = Date(this.inWholeMilliseconds) + return itemCalendar + } +} + +private const val DAYS_IN_ONE_YEAR = 365 + diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetColorHexWithNameList.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetColorHexWithNameList.kt new file mode 100644 index 0000000000..2e7d38ba17 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetColorHexWithNameList.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import ch.protonmail.android.mailcommon.presentation.R +import ch.protonmail.android.mailcommon.presentation.model.ColorHexWithName +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import javax.inject.Inject + +class GetColorHexWithNameList @Inject constructor() { + + operator fun invoke(): List { + return listOf( + ColorHexWithName(TextUiModel(R.string.color_purple), Colors.PurpleBase), + ColorHexWithName(TextUiModel(R.string.color_enzian), Colors.EnzianBase), + ColorHexWithName(TextUiModel(R.string.color_pink), Colors.PinkBase), + ColorHexWithName(TextUiModel(R.string.color_plum), Colors.PlumBase), + ColorHexWithName(TextUiModel(R.string.color_strawberry), Colors.StrawberryBase), + ColorHexWithName(TextUiModel(R.string.color_cerise), Colors.CeriseBase), + ColorHexWithName(TextUiModel(R.string.color_carrot), Colors.CarrotBase), + ColorHexWithName(TextUiModel(R.string.color_copper), Colors.CopperBase), + ColorHexWithName(TextUiModel(R.string.color_sahara), Colors.SaharaBase), + ColorHexWithName(TextUiModel(R.string.color_soil), Colors.SoilBase), + ColorHexWithName(TextUiModel(R.string.color_slate_blue), Colors.SlateBlueBase), + ColorHexWithName(TextUiModel(R.string.color_cobalt), Colors.CobaltBase), + ColorHexWithName(TextUiModel(R.string.color_pacific), Colors.PacificBase), + ColorHexWithName(TextUiModel(R.string.color_ocean), Colors.OceanBase), + ColorHexWithName(TextUiModel(R.string.color_reef), Colors.ReefBase), + ColorHexWithName(TextUiModel(R.string.color_pine), Colors.PineBase), + ColorHexWithName(TextUiModel(R.string.color_fern), Colors.FernBase), + ColorHexWithName(TextUiModel(R.string.color_forest), Colors.ForestBase), + ColorHexWithName(TextUiModel(R.string.color_olive), Colors.OliveBase), + ColorHexWithName(TextUiModel(R.string.color_pickle), Colors.PickleBase) + ) + } + + object Colors { + const val PurpleBase = "#FF8080FF" + const val EnzianBase = "#FF5252CC" + const val PinkBase = "#FFDB60D6" + const val PlumBase = "#FFA839A4" + const val StrawberryBase = "#FFEC3E7C" + const val CeriseBase = "#FFBA1E55" + const val CarrotBase = "#FFF78400" + const val CopperBase = "#FFC44800" + const val SaharaBase = "#FF936D58" + const val SoilBase = "#FF54473F" + const val SlateBlueBase = "#FF415DF0" + const val CobaltBase = "#FF273EB2" + const val PacificBase = "#FF179FD9" + const val OceanBase = "#FF0A77A6" + const val ReefBase = "#FF1DA583" + const val PineBase = "#FF0F735A" + const val FernBase = "#FF3CBB3A" + const val ForestBase = "#FF258723" + const val OliveBase = "#FFB4A40E" + const val PickleBase = "#FF807304" + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitial.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitial.kt new file mode 100644 index 0000000000..53262b2c62 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitial.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import javax.inject.Inject + +class GetInitial @Inject constructor() { + + operator fun invoke(value: String): String? { + val firstChar = value.trim() + .firstOrNull() + ?.uppercaseChar() + ?: return null + + val stringBuilder = StringBuilder().append(firstChar) + + if (firstChar.isHighSurrogate()) { + value.getOrNull(1)?.let { followingChar -> + stringBuilder.append(followingChar) + } + } + return stringBuilder.toString() + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitials.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitials.kt new file mode 100644 index 0000000000..f66b191090 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitials.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import javax.inject.Inject + +class GetInitials @Inject constructor() { + + operator fun invoke(value: String): String { + if (value.isBlank()) return "" + + val initials = value.uppercase().split(' ').mapNotNull { + val firstChar = it.firstOrNull() ?: return@mapNotNull null + val stringBuilder = StringBuilder().append(firstChar) + + if (firstChar.isHighSurrogate()) { + it.getOrNull(1)?.let { followingChar -> + stringBuilder.append(followingChar) + } + } else if (!firstChar.isDefined()) return@mapNotNull null + + stringBuilder.toString() + }.reduceOrNull { acc, s -> acc + s } ?: return "" + + // Keep only the first and last initials + return if (initials.length > 2) initials[0].toString() + initials[initials.lastIndex] else initials + } +} diff --git a/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModel.kt b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModel.kt new file mode 100644 index 0000000000..d510b885d2 --- /dev/null +++ b/mail-common/presentation/src/main/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.domain.usecase.UndoLastOperation +import ch.protonmail.android.mailcommon.presentation.Effect +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UndoOperationViewModel @Inject constructor( + private val undoLastOperation: UndoLastOperation +) : ViewModel() { + + private val mutableState = MutableStateFlow(Initial) + val state = mutableState.asStateFlow() + + fun submitUndo() = viewModelScope.launch { + undoLastOperation().fold( + ifLeft = { mutableState.emit(state.value.copy(undoFailed = Effect.of(Unit))) }, + ifRight = { mutableState.emit(state.value.copy(undoSucceeded = Effect.of(Unit))) } + ) + } + + data class State( + val undoSucceeded: Effect, + val undoFailed: Effect + ) + + companion object { + private val Initial = State(Effect.empty(), Effect.empty()) + } +} diff --git a/mail-common/presentation/src/main/res/drawable/ic_cake.xml b/mail-common/presentation/src/main/res/drawable/ic_cake.xml new file mode 100644 index 0000000000..7d721d5aee --- /dev/null +++ b/mail-common/presentation/src/main/res/drawable/ic_cake.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/mail-common/presentation/src/main/res/drawable/ic_m_plus_rainbow.xml b/mail-common/presentation/src/main/res/drawable/ic_m_plus_rainbow.xml new file mode 100644 index 0000000000..450847fda2 --- /dev/null +++ b/mail-common/presentation/src/main/res/drawable/ic_m_plus_rainbow.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/mail-common/presentation/src/main/res/drawable/ic_wand.xml b/mail-common/presentation/src/main/res/drawable/ic_wand.xml new file mode 100644 index 0000000000..b87ff7912f --- /dev/null +++ b/mail-common/presentation/src/main/res/drawable/ic_wand.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mail-common/presentation/src/main/res/values-b+es+419/strings.xml b/mail-common/presentation/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 0000000000..16e43735a7 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,141 @@ + + + + Reintentar + Volver a la pantalla anterior + Sesión no iniciada + Responder + Responder a todos + Reenviar + Responder + Responder a todos + Reenviar + Marcar como leído + Marcar como no leído + Destacar + No destacar + Etiquetar como… + Mover a… + Mover a la papelera + Eliminar + Archivar + Mover a spam + Ver mensaje en modo claro + Ver mensaje en modo oscuro + Imprimir + Ver encabezados + Ver HTML + Reportar suplantación de identidad + Recordarme + Guardar como PDF + Definir acciones para futuros correos de este remitente + Guardar adjuntos + Más + Responder + Responder a todos + Reenviar + Marcar como leído + Marcar como no leído + Destacar + No destacar + Etiquetar como… + Mover a… + Papelera + Eliminar + Archivo + Spam + Ver mensaje en modo claro + Ver mensaje en modo oscuro + Imprimir + Ver encabezados + Ver HTML + Reportar suplantación de identidad + Recordarme + Guardar como PDF + Definir acciones para futuros correos de este remitente + Guardar adjuntos + Más + Error al cargar las acciones + Ayer + Personalizar la barra de herramientas + Personalizar la barra de herramientas + Elimine mensajes automáticamente que han estado en la papelera por más de 30 días. + Elimine mensajes automáticamente que han estado en spam por más de 30 días. + Mejore su plan para eliminar automáticamente los mensajes que hayan estado en la papelera o spam por más de 30 días. + Los mensajes que hayan estado en la papelera o spam por más de 30 días serán eliminados automáticamente. + Activar + No, gracias + Más información + + Expirada + %d d + %d h + %d m + Este mensaje se eliminará automáticamente en menos de una hora. + Este mensaje se eliminará automáticamente en menos de un día. + Este mensaje se eliminará automáticamente en %s. + + %d minuto + %d minutos + + + %d hora + %d horas + + + %d día + %d días + + + Archivos adjuntos + Notificaciones de descargas de archivos adjuntos + Correos + Alertas de inicio de sesión + Notificaciones de correo electrónico entrante + Notificaciones de nuevos inicios de sesión + Oficial + No se encontró una aplicación para ejecutar esta acción + Copiado al portapapeles + Cancelar + Eliminar + Cancelar + Morado + Genciana + Rosa + Ciruela + Fresa + Cereza + Zanahoria + Cobre + Sahara + Suelo + Azul pizarra + Cobalto + Pacífico + Océano + Arrecife + Pino + Helecho + Bosque + Olivo + Pepinillo + Deshacer + Se canceló la acción. + Error al revertir la acción + diff --git a/mail-common/presentation/src/main/res/values-be/strings.xml b/mail-common/presentation/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..3823f0a1cd --- /dev/null +++ b/mail-common/presentation/src/main/res/values-be/strings.xml @@ -0,0 +1,147 @@ + + + + Паўтарыць + Вярнуцца на папярэдні экран + Уваход не выкананы + Адказаць + Адказаць усім + Пераслаць + Адказаць + Адказаць усім + Пераслаць + Пазначыць прачытаным + Пазначыць непрачытаным + Пазначыць зорачкай + Прыбраць зорачку + Пазначыць як… + Перамясціць у… + Перамясціць у сметніцу + Выдаліць + Архіваваць + Перамясціць у спам + Паглядзець паведамленні ў светлым рэжыме + Паглядзець паведамленні ў цёмным рэжыме + Друкаваць + Прагледзець загалоўкі + Паглядзець HTML + Паведаміць пра фішынг + Нагадаць мне + Захаваць як PDF + Задаць дзеянне для будучых лістоў ад гэтага адпраўніка + Захаваць далучэнні + Больш + Адказаць + Адказаць усім + Пераслаць + Пазначыць прачытаным + Пазначыць непрачытаным + Пазначыць зорачкай + Прыбраць зорачку + Пазначыць як… + Перамясціць у… + Сметніца + Выдаліць + Архіваваць + Спам + Паглядзець паведамленні ў светлым рэжыме + Паглядзець паведамленні ў цёмным рэжыме + Друкаваць + Прагледзець загалоўкі + Паглядзець HTML + Паведаміць пра фішынг + Нагадаць мне + Захаваць як PDF + Задаць дзеянне для будучых лістоў ад гэтага адпраўніка + Захаваць далучэнні + Больш + Не ўдалося загрузіць дзеянні + Учора + Наладзіць панэль інструментаў + Наладзіць панэль інструментаў + Аўтаматычна выдаляць паведамленні, якія знаходзяцца ў папцы сметніца больш за 30 дзён. + Аўтаматычна выдаляць паведамленні, якія знаходзяцца ў папцы спам больш за 30 дзён. + Палепшыце свой тарыфны план, каб аўтаматычна выдаляць паведамленні, якія знаходзяцца ў папках сметніца або спам больш за 30 дзён. + Паведамленні, якія знаходзяцца ў папках сметніца і спам больш за 30 дзён будуць выдаляцца аўтаматычна. + Уключыць + Не, дзякуй + Даведацца больш + + Пратэрмінавана + %d д + %d г + %d хв + Гэта паведамленне будзе аўтаматычна выдалена менш чым праз гадзіну + Гэта паведамленне будзе аўтаматычна выдалена менш чым праз дзень + Гэта паведамленне будзе аўтаматычна выдалена за %s + + %d хвіліна + %d хвіліны + %d хвілін + %d хвілін + + + %d гадзіна + %d гадзіны + %d гадзін + %d гадзін + + + %d дзень + %d дні + %d дзён + %d дзён + + + Далучэнні + Апавяшчэнні аб спампоўванні далучэнняў + Лісты + Папярэджанні ўваходу + Апавяшчэнні пра ўваходную электронную пошту + Новыя апавяшчэнні пра ўваходы + Афіцыйны + Не знойдзена праграм для апрацоўкі гэтага дзеяння + Скапіявана ў буфер абмену + Скасаваць + Выдаліць + Скасаваць + Фіялетавы + Чарнільны + Ружовы + Слівавы + Клубнічны + Вішнёвы + Маркоўны + Медны + Сахара + Зямлісты + Сланцава-блакітны + Кобальтавы + Ціхаакіянскі + Акіянічны + Рыфавы + Сасновы + Папаратнікавы + Лясны + Аліўкавы + Агурочны + Адрабіць + Дзеянне вернута + Збой вяртання дзеяння + diff --git a/mail-common/presentation/src/main/res/values-ca/strings.xml b/mail-common/presentation/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..8b3298be28 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ca/strings.xml @@ -0,0 +1,141 @@ + + + + Reintenta + Torna a la pantalla anterior + No s\'ha iniciat la sessió + Respon + Respon a tothom + Reenvia + Respon + Respon a tothom + Reenvia + Marca com a llegit + Marca com a no llegit + Destaca + No destaquis + Etiqueta com a… + Mou a… + Mou a la paperera + Elimina + Arxiva + Mou al correu brossa + Mostra els missatges en mode clar + Mostra els missatges en mode fosc + Imprimeix + Mostra les capçaleres + Mostra el codi HTML + Denuncia una suplantació d\'identitat + Recorda-m\'ho + Desa com a PDF + Estableix l\'acció per a correus electrònics futurs d\'aquest remitent + Desa els adjunts + Més + Respon + Respon a tothom + Reenvia + Marca com a llegit + Marca com a no llegit + Destaca + Treure de destacats + Etiqueta com a… + Mou a… + Paperera + Elimina + Arxiva + Correu brossa + Mostra els missatges en mode clar + Mostra els missatges en mode fosc + Imprimeix + Mostra les capçaleres + Mostra el codi HTML + Denuncia una suplantació d\'identitat + Recorda-m\'ho + Desa com a PDF + Estableix l\'acció per a correus electrònics futurs d\'aquest remitent + Desa els adjunts + Més + No s\'han pogut carregar les accions + Ahir + Personalitza la barra d\'eines + Personalitza la barra d\'eines + Suprimeix automàticament els missatges que han estat a la paperera durant més de 30 dies. + Suprimeix automàticament els missatges que han estat al correu brossa durant més de 30 dies. + Milloreu el vostre pla per a eliminar automàticament els missatges que han estat a la paperera o al correu brossa durant més de 30 dies. + Els missatges que hagin estat a la paperera i al correu brossa més de 30 dies s\'eliminaran automàticament. + Activa + No, gràcies + Més informació + + Caducat + %d d + %d h + %d m + Aquest missatge s\'eliminarà automàticament en menys d\'una hora. + Aquest missatge s\'eliminarà automàticament en menys d\'un dia. + Aquest missatge s\'eliminarà automàticament en %s. + + %d minut + %d minuts + + + %d hora + %d hores + + + %d dia + %d dies + + + Adjunts + Notificacions per a descàrregues d\'adjunts + Correus electrònics + Alertes d\'inici de sessió + Notificacions de correu electrònic entrant + Noves notificacions d\'inici de sessió + Oficial + No s\'ha trobat cap aplicació per a gestionar aquesta acció + S\'ha copiat al porta-retalls. + Cancel·la + Elimina + Cancel·la + Porpra + Genciana + Rosa + Pruna + Maduixa + Vermell cirera + Pastanaga + Coure + Sahara + Terra + Blau pissarra + Cobalt + Pacífic + Blau oceà + Escull + Verd pi + Verd falguera + Verd bosc + Verd oliva + Verd cogombre + Desfer + S\'ha desfet l\'acció + No s\'ha pogut desfer l\'acció + diff --git a/mail-common/presentation/src/main/res/values-cs/strings.xml b/mail-common/presentation/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..6bec05f054 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-cs/strings.xml @@ -0,0 +1,147 @@ + + + + Obnovit + Zpět na předchozí obrazovku + Nejste přihlášeni + Odpovědět + Odpovědět všem + Přeposlat + Odpovědět + Odpovědět všem + Přeposlat + Označit jako přečtené + Označit jako nepřečtené + Označit hvězdičkou + Odebrat hvězdičku + Označit jako… + Přesunout do… + Přesunout do koše + Smazat + Archivovat + Přesunout do spamu + Zobrazit zprávu ve světlém režimu + Zobrazit zprávu v tmavém režimu + Tisk + Zobrazit hlavičku + Zobrazit HTML + Nahlásit phishing + Připomenout + Uložit jako PDF + Nastavit akci pro budoucí e-maily od tohoto odesílatele + Uložit přílohy + Další + Odpovědět + Odpovědět všem + Přeposlat + Označit jako přečtené + Označit jako nepřečtené + Označit hvězdičkou + Odebrat hvězdičku + Označit jako… + Přesunout do… + Koš + Odstranit + Archivovat + Spam + Zobrazit zprávu ve světlém režimu + Zobrazit zprávu v tmavém režimu + Tisk + Zobrazit záhlaví + Zobrazit HTML + Nahlásit phishing + Připomenout + Uložit jako PDF + Nastavit akci pro budoucí e-maily od tohoto odesílatele + Uložit přílohy + Více + Načtení akcí selhalo + Včera + Přizpůsobit panel nástrojů + Přizpůsobit panel nástrojů + Automaticky smazat zprávy, které jsou v koši déle než 30 dní. + Automaticky smazat zprávy, které jsou ve spamu déle než 30 dní. + Navyšte svůj tarif pro automatické mazání zpráv, které jsou v koši nebo spamu déle než 30 dní. + Zprávy, které byly v koši a spamu déle než 30 dní, budou automaticky smazány. + Povolit + Ne, děkuji + Zjistit více + + Expirovala + %dd + %dh + %dm + Tato zpráva bude automaticky smazána za méně než hodinu + Tato zpráva bude automaticky smazána za méně než den + Tato zpráva bude automaticky smazána za %s + + %d minuta + %d minuty + %d minut + %d minut + + + %d hodina + %d hodiny + %d hodin + %d hodin + + + %d den + %d dny + %d dnů + %d dnů + + + Přílohy + Upozornění na stahování příloh + E-maily + Upozornění na přihlášení + Oznámení příchozích e-mailů + Upozornění na nová přihlášení + Oficiální + Pro zpracování této akce nebyla nalezena žádná aplikace + Zkopírováno do schránky + Zrušit + Smazat + Zrušit + Purpurová + Hořec + Růžová + Švestka + Jahoda + Třešeň + Mrkev + Měď + Sahara + Půda + Modrá břidlice + Kobalt + Pacifik + Oceán + Útes + Borovice + Kapradí + Les + Oliva + Okurka + Zpět + Akce vrácena + Vrácení selhalo + diff --git a/mail-common/presentation/src/main/res/values-da/strings.xml b/mail-common/presentation/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..c9415a3548 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-da/strings.xml @@ -0,0 +1,141 @@ + + + + Forsøg igen + Tilbage til forrige skærm + Ikke logget ind + Svar + Svar alle + Videresend + Svar + Svar alle + Videresend + Markér som læst + Markér som ulæst + Stjernemarkér + Fjern stjernemarkering + Tilføj etiket… + Flyt til… + Flyt til papirkurv + Slet + Arkivér + Flyt til spam + Se besked i lys tilstand + Se besked i mørk tilstand + Udskriv + Vis headeroplysninger + Vis HTML + Anmeld phishing + Påmind mig + Gem som PDF + Indstil handling for fremtidige e-mails fra denne afsender + Gem vedhæftninger + Flere + Svar + Svar alle + Videresend + Markér som læst + Markér som ulæst + Stjernemarkér + Fjern stjernemarkering + Tilføj etiket… + Flyt til… + Flyt til papirkurv + Slet + Arkivér + Spam + Se besked i lys tilstand + Se besked i mørk tilstand + Udskriv + Vis overskrifter + Se HTML + Anmeld phishing + Påmind mig + Gem som PDF + Indstil handling for fremtidige e-mails fra denne afsender + Gem vedhæftninger + Mere + Kunne ikke indlæse handlinger + I går + Tilpas værktøjslinje + Tilpas værktøjslinje + Slet automatisk beskeder, der har været i papirkurven i mere end 30 dage. + Slet automatisk beskeder, der har været i spam i mere end 30 dage. + Opgradér for automatisk at slette beskeder, der har været i papirkurven eller spam i mere end 30 dage. + Beskeder der har været i papirkurven og spam i mere end 30 dage vil blive slettet automatisk. + Aktiver + Nej tak + Få mere at vide + + Udløbet + %d d + %d t + %d m + Denne besked bliver automatisk slettet om mindre end en time + Denne besked bliver automatisk slettet om mindre end en dag + Denne besked bliver automatisk slettet om %s + + %d minut + %d minutter + + + %d time + %d timer + + + %d dag + %d dage + + + Vedhæftninger + Notifikationer ved download af vedhæftninger + E-mails + Login-varsler + Indgående e-mail-meddelelser + Notifikationer om nye logins + Officiel + Ingen app til håndtering af denne handling fundet + Kopieret til udklipsholder + Annullér + Slet + Annullér + Lilla + Ensian + Pink + Blomme + Jordbær + Cerise + Gulerod + Kobber + Sahara + Jord + Blågrå + Kobolt + Stillehav + Hav + Rev + Fyr + Bregne + Skov + Oliven + Agurk + Fortryd + Handling tilbageført + Tilbageførsel af handling fejlede + diff --git a/mail-common/presentation/src/main/res/values-de/strings.xml b/mail-common/presentation/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..4ae2fa8337 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-de/strings.xml @@ -0,0 +1,141 @@ + + + + Wiederholen + Zurück zum vorherigen Bildschirm + Nicht angemeldet + Antworten + Allen antworten + Weiterleiten + Antworten + Allen antworten + Weiterleiten + Als gelesen markieren + Als ungelesen markieren + Favorit + Kein Favorit + Kategorisieren als … + Verschieben nach … + In den Papierkorb verschieben + Löschen + Archivieren + In den Spam-Ordner verschieben + Nachricht im hellen Modus anzeigen + Nachricht im dunklen Modus anzeigen + Drucken + Header anzeigen + Als HTML anzeigen + Phishing melden + Erinnere mich + Als PDF speichern + Aktion für zukünftige E-Mails von diesem Absender festlegen + Anhänge speichern + Mehr + Antworten + Allen antworten + Weiterleiten + Als gelesen markieren + Als ungelesen markieren + Markieren + Markierung entfernen + Kategorisieren als … + Verschieben nach … + Papierkorb + Löschen + Archivieren + Spam + Nachricht im hellen Modus anzeigen + Nachricht im dunklen Modus anzeigen + Drucken + Kopfzeilen anzeigen + Als HTML anzeigen + Phishing melden + Erinnere mich + Als PDF speichern + Aktion für zukünftige E-Mails von diesem Absender festlegen + Anhänge speichern + Mehr + Aktionen konnten nicht geladen werden + Gestern + Symbolleiste anpassen + Symbolleiste anpassen + Nachrichten, die länger als 30 Tage im Papierkorb liegen, automatisch löschen. + Nachrichten, die länger als 30 Tage im Spam-Ordner liegen, automatisch löschen. + Führe ein Upgrade durch, um Nachrichten, die sich seit mehr als 30 Tagen im Papierkorb oder Spam befinden automatisch zu löschen. + Nachrichten, die länger als 30 Tage im Papierkorb und Spam liegen, werden automatisch gelöscht. + Aktivieren + Nein, danke + Mehr erfahren + + Abgelaufen + %d Tag(e) + %d Stunde(n) + %d Minute(n) + Diese Nachricht wird in weniger als einer Stunde automatisch gelöscht + Diese Nachricht wird in weniger als einem Tag automatisch gelöscht + Diese Nachricht wird in %s automatisch gelöscht + + %d Minute + %d Minuten + + + %d Stunde + %d Stunden + + + %d Tag + %d Tage + + + Anhänge + Benachrichtigungen für Anhang-Downloads + E-Mails + Login-Warnungen + Benachrichtigungen über eingehende Nachrichten + Benachrichtigungen zu neuen Anmeldungen + Offiziell + Keine App verfügbar, um diese Aktion auszuführen + In die Zwischenablage kopiert + Abbrechen + Löschen + Abbrechen + Violett + Enzian + Rosa + Pflaume + Erdbeere + Kirschrot + Karotte + Kupfer + Sahara + Erde + Schieferblau + Kobaltblau + Pazifikblau + Ozeanblau + Koralle + Kiefer + Farn + Waldgrün + Olivgrün + Moosgrün + Rückgängig machen + Vorgang wurde Rückgängig gemacht + Aktion konnte nicht rückgängig gemacht werden + diff --git a/mail-common/presentation/src/main/res/values-el/strings.xml b/mail-common/presentation/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..c6b84be55c --- /dev/null +++ b/mail-common/presentation/src/main/res/values-el/strings.xml @@ -0,0 +1,141 @@ + + + + Δοκιμάστε ξανά + Επιστροφή στην προηγούμενη οθόνη + Δεν έχει γίνει είσοδος + Απάντηση + Απάντηση σε όλους + Προώθηση + Απάντηση + Απάντηση σε όλους + Προώθηση + Επισήμανση ως αναγνωσμένο + Επισήμανση ως μη αναγνωσμένο + Αστέρι + Αφαίρεση αστεριού + Ετικέτα ως… + Μετακίνηση σε… + Μετακίνηση στα Διαγραμμένα + Διαγραφή + Αρχειοθετημένα + Μετακίνηση στα ενοχλητικά + Προβολή μηνύματος σε φωτεινή διαμόρφωση + Προβολή μηνύματος σε σκούρα διαμόρφωση + Εκτύπωση + Προβολή Κεφαλίδων + Προβολή HTML + Αναφορά ηλεκτρονικού «ψαρέματος» + Υπενθύμισέ μου + Αποθήκευση ως PDF + Ορισμός ενέργειας για μελλοντικά μηνύματα ηλεκτρονικού ταχυδρομείου από αυτόν τον αποστολέα + Αποθήκευση συνημμένων + Περισσότερα + Απάντηση + Απάντηση σε όλους + Προώθηση + Επισήμανση ως αναγνωσμένο + Επισήμανση ως μη αναγνωσμένο + Αστέρι + Αφαίρεση αστεριού + Ετικέτα ως… + Μετακίνηση σε… + Διαγραφή + Διαγραφή + Αρχειοθέτηση + Ενοχλητικά + Προβολή μηνύματος σε φωτεινή διαμόρφωση + Προβολή μηνύματος σε σκούρα διαμόρφωση + Εκτύπωση + Προβολή Κεφαλίδων + Προβολή HTML + Αναφορά ηλεκτρονικού «ψαρέματος» + Υπενθύμισέ μου + Αποθήκευση ως PDF + Ορισμός ενέργειας για μελλοντικά μηνύματα ηλεκτρονικού ταχυδρομείου από αυτόν τον αποστολέα + Αποθήκευση συνημμένων + Περισσότερα + Αποτυχία φόρτωσης ενεργειών + Χθες + Προσαρμογή γραμμής εργαλείων + Προσαρμογή γραμμής εργαλείων + Αυτόματη οριστική εξάλειψη μηνυμάτων που παραμένουν στα Διαγραμμένα για περισσότερες από 30 ημέρες. + Αυτόματη διαγραφή μηνυμάτων που παραμένουν στα Ενοχλητικά για περισσότερες από 30 ημέρες. + Αναβαθμίστε για αυτόματη οριστική εξάλειψη των μηνυμάτων που παραμένουν στα Διαγραμμένα ή στα Ενοχλητικά για περισσότερες από 30 ημέρες. + Μηνύματα που παραμένουν στα Διαγραμμένα ή στα Ενοχλητικά για περισσότερες από 30 ημέρες θα διαγράφονται αυτόματα και οριστικά. + Ενεργοποίηση + Όχι, ευχαριστώ + Μάθετε περισσότερα + + Ληγμένο + %dμ + %dω + %dλ + Αυτό το μήνυμα θα διαγραφεί αυτόματα σε λιγότερο από μία ώρα + Αυτό το μήνυμα θα διαγραφεί αυτόματα σε λιγότερο από μία μέρα + Αυτό το μήνυμα θα διαγραφεί αυτόματα σε %s + + %d λεπτό + %d λεπτά + + + %d ώρα + %d ώρες + + + %d ημέρα + %d ημέρες + + + Συνημμένα + Ειδοποιήσεις για λήψεις συνημμένων + Μηνύματα + Ειδοποιήσεις σύνδεσης + Ειδοποιήσεις εισερχόμενων E-mail + Ειδοποιήσεις νέων συνδέσεων + Επίσημο + Δεν βρέθηκε εφαρμογή να χειριστεί αυτήν την ενέργεια + Αντιγράφηκε στο πρόχειρο + Ακύρωση + Διαγραφή + Ακύρωση + Μωβ + Ενζιάν + Ροζ + Δαμασκηνί + Φραουλί + Κερίζ + Καροτί + Χαλκός + Σαχάρα + Χώμα + Βιολετί + Κοβάλτιο + Ειρηνικός + Ωκεανός + Ύφαλος + Πεύκο + Φτέρη + Δάσος + Λαδί + Τουρσί + Αναίρεση + Η ενέργεια αναιρέθηκε + Η αναίρεση απέτυχε + diff --git a/mail-common/presentation/src/main/res/values-es-rES/strings.xml b/mail-common/presentation/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..d4a25cb213 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,141 @@ + + + + Reintentar + Volver a la pantalla anterior + No se ha iniciado sesión. + Responder + Responder a todos + Reenviar + Responder + Responder a todos + Reenviar + Marcar como leído + Marcar como no leído + Destacar + No destacar + Etiquetar como… + Mover a… + Mover a la papelera + Eliminar + Archivar + Mover a la carpeta de spam + Ver el mensaje en modo claro + Ver el mensaje en modo oscuro + Imprimir + Ver los encabezados + Ver el código HTML + Denunciar una suplantación de identidad + Recordarme + Guardar como PDF + Definir acciones para futuros correos de este remitente + Guardar los adjuntos + Más + Responder + Responder a todos + Reenviar + Marcar como leído + Marcar como no leído + Destacar + No destacar + Etiquetar como… + Mover a… + Papelera + Eliminar + Archivar + Spam + Ver mensaje en modo claro + Ver mensaje en modo oscuro + Imprimir + Ver encabezados + Ver HTML + Denunciar suplantación de identidad + Recordarme + Guardar como PDF + Definir acciones para futuros correos de este remitente + Guardar adjuntos + Más + Error al cargar las acciones + Ayer + Personalizar la barra de herramientas + Personalizar la barra de herramientas + Elimina mensajes automáticamente que han estado en la carpeta Papelera por más de 30 días. + Elimina mensajes automáticamente que han estado en la carpeta Spam por más de 30 días. + Mejora tu plan para eliminar automáticamente los mensajes que hayan estado en la carpeta Papelera o Spam por más de 30 días. + Los mensajes que hayan estado en la carpeta Papelera o Spam por más de 30 días serán eliminados automáticamente. + Activar + No, gracias + Más información + + Expirado + %d d + %d h + %d m + Este mensaje se eliminará automáticamente en menos de una hora. + Este mensaje se eliminará automáticamente en menos de un día. + Este mensaje se eliminará automáticamente en %s. + + %d minuto + %d minutos + + + %d hora + %d horas + + + %d día + %d días + + + Archivos adjuntos + Notificaciones de descargas de archivos adjuntos + Correos electrónicos + Alertas de inicio de sesión + Notificaciones de correo electrónico entrante + Notificaciones de nuevos inicios de sesión + Oficial + No se ha encontrado una aplicación para ejecutar esta acción. + Copiado al portapapeles + Cancelar + Eliminar + Cancelar + Púrpura + Genciana + Rosa + Ciruela + Fresa + Cereza + Zanahoria + Cobre + Sáhara + Tierra + Azul pizarra + Cobalto + Pacífico + Océano + Arrecife + Pino + Helecho + Bosque + Aceituna + Pepinillo + Deshacer + Se ha cancelado la acción. + Error al cancelar la acción + diff --git a/mail-common/presentation/src/main/res/values-fi/strings.xml b/mail-common/presentation/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..5e42352406 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-fi/strings.xml @@ -0,0 +1,141 @@ + + + + Yritä uudelleen + Palaa edelliselle ruudulle + Ei kirjautuneena + Vastaa + Vastaa kaikille + Välitä + Vastaa + Vastaa kaikille + Välitä + Merkitse luetuksi + Merkitse lukemattomaksi + Merkitse tähdellä + Poista tähti + Merkitse tunnisteella… + Siirrä kohteeseen… + Siirrä roskakoriin + Poista + Arkistoi + Siirrä roskapostiin + Näytä viesti vaaleassa tilassa + Näytä viesti tummassa tilassa + Tulosta + Näytä otsakkeet + Näytä HTML + Ilmoita tietojenkalastelusta + Muistuta minua + Tallenna PDF-tiedostona + Aseta tämän lähettäjän viesteille jatkossa suoritettava toiminto + Tallenna liitteet + Lisää + Vastaa + Vastaa kaikille + Välitä + Merkitse luetuksi + Merkitse lukemattomaksi + Lisää tähti + Poista tähti + Merkitse tunnisteella… + Siirrä kohteeseen… + Roskakori + Poista + Arkisto + Roskaposti + Näytä viesti vaaleassa tilassa + Näytä viesti tummassa tilassa + Tulosta + Näytä otsakkeet + Näytä HTML + Ilmoita tietojenkalastelusta + Muistuta minua + Tallenna PDF-tiedostona + Aseta tämän lähettäjän viesteille jatkossa suoritettava toiminto + Tallenna liitteet + Lisää + Toimintojen lataus epäonnistui + Eilen + Mukauta työkalupalkkia + Mukauta työkalupalkkia + Poista roskakorissa yli 30 päivää olleet viestit automaattisesti. + Poista roskapostissa yli 30 päivää olleet viestit automaattisesti. + Päivitä tilauksesi käyttääksesi roskakorissa ja roskapostissa yli 30 päivää olleiden viestien automaattista poistoa. + Roskakorissa ja roskapostissa yli 30 päivää olleet viestit poistetaan automaattisesti. + Ota käyttöön + Ei kiitos + Lue lisää + + Tuhoutunut + %dpv + %dt + %dmin + Tämä viesti poistetaan automaattisesti vajaan tunnin kuluttua + Tämä viesti poistetaan automaattisesti vajaan vuorokauden kuluttua + Tämä viesti poistetaan automaattisesti %s + + %d minuuttia + %d minuuttia + + + %d tunti + %d tuntia + + + %d vuorokausi + %d vuorokautta + + + Liitteet + Ilmoitukset liitetiedostojen latauksista + Viestit + Kirjautumishälytykset + Ilmoitukset saapuneista viesteistä + Ilmoitukset uusista kirjautumisista + Virallinen + Toiminnon suorittamiseen sopivaa sovellusta ei löytynyt + Kopioitiin leikepöydälle + Peruuta + Poista + Peruuta + Violetti + Enzian + Pinkki + Luumu + Mansikka + Kirsikka + Porkkana + Kupari + Sahara + Multa + Liuskeensininen + Koboltti + Tyynimeri + Valtameri + Riutta + Mänty + Saniainen + Metsä + Oliivi + Suolakurkku + Kumoa + Toiminto peruttiin + Toiminnon peruminen epäonnistui + diff --git a/mail-common/presentation/src/main/res/values-fr/strings.xml b/mail-common/presentation/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..c8970d1b1b --- /dev/null +++ b/mail-common/presentation/src/main/res/values-fr/strings.xml @@ -0,0 +1,141 @@ + + + + Réessayer + Retour à l\'écran précédent + Non connecté + Répondre + Répondre à tous + Transférer + Répondre + Répondre à tous + Transférer + Marquer comme lu + Marquer comme non lu + Ajouter aux Favoris + Retirer des favoris + Labelliser… + Déplacer vers… + Déplacer dans le dossier Corbeille + Supprimer + Déplacer dans le dossier Archives + Déplacer dans le dossier Indésirables/spam + Voir le message en mode clair + Voir le message en mode sombre + Imprimer + Voir les en-têtes + Afficher le code HTML + Signaler un phishing/hameçonnage + Me le rappeler + Enregistrer en PDF + Définir l\'action pour les prochains messages de cet expéditeur + Enregistrer les pièces jointes + Plus + Répondre + Répondre à tous + Transférer + Marquer comme lu + Marquer comme non lu + Ajouter aux Favoris + Retirer des Favoris + Labelliser… + Déplacer vers… + Corbeille + Supprimer + Archiver + Indésirables/spam + Voir le message en mode clair + Voir le message en mode sombre + Imprimer + Voir les en-têtes + Voir le HTML + Signaler un phishing/hameçonnage + Me rappeler + Enregistrer en PDF + Définir l\'action pour les prochains messages de cet expéditeur + Enregistrer les pièces jointes + Plus + Les actions n\'ont pu être chargées. + Hier + Personnaliser la barre d\'outils + Personnaliser la barre d\'outils + Supprimez automatiquement les messages qui sont dans le dossier Corbeille depuis plus de 30 jours. + Supprimez automatiquement les messages qui sont dans le dossier Indésirables/spam depuis plus de 30 jours. + Changez d\'abonnement pour supprimer automatiquement les messages dans les dossiers Corbeille ou Indésirables/spam depuis plus de 30 jours. + Les messages dans les dossiers Corbeille et Indésirables/spam depuis plus de 30 jours seront automatiquement supprimés. + Activer + Non, merci + En savoir plus + + Expiré + %d j + %d h + %d m + Ce message sera automatiquement supprimé dans moins d\'une heure. + Ce message sera automatiquement supprimé dans moins d\'un jour. + Ce message sera automatiquement supprimé dans %s. + + %d minute + %d minutes + + + %d heure + %d heures + + + %d jour + %d jours + + + Pièces jointes + Notifications pour les téléchargements de pièces jointes + Messages + Alertes de connexion + Notifications de réception de messages + Notifications de nouvelles connexions + Officiel + Aucune application n’a été trouvée pour gérer cette action. + Copié dans le presse-papiers + Annuler + Supprimer + Annuler + Violet + Gentiane + Rose + Prune + Fraise + Cerise + Carotte + Cuivre + Sahara + Terre + Bleu ardoise + Cobalt + Pacifique + Océan + Récif + Pin + Fougère + Forêt + Olive + Cornichon + Annuler + Action annulée + L\'action n\'a pas pu être annulée. + diff --git a/mail-common/presentation/src/main/res/values-hi/strings.xml b/mail-common/presentation/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000000..2c2a3d33c5 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-hi/strings.xml @@ -0,0 +1,141 @@ + + + + वापस कोशिश करें + पिछले स्क्रीन पे जाएं + साइन इन नहीं हैं + जवाब दें + सभी को जवाब दें + फ़ॉरवर्ड करें + जवाब दें + सभी का जवाब दें + फ़ॉरवर्ड करें + पढ़ा मार्क करें + नहीं पढ़ा मार्क करें + तारा लगाएं + तारा हटाएं + लेबल करें… + ले जाएं… + कूड़ेदान में ले जाएँ + मिटाएं + संग्रह + स्पैम में ले जाएं + संदेश को रोशनी मोड में देखें + संदेश को अंधेरे मोड में देखें + प्रिंट करें + हैडर देखें + HTML देखें + फिशिंग रिपोर्ट करें + मुझे याद दिलाना + PDF में सेव करें + इस इंसान से भविष्य में जो ईमेल मिलें उसके लिएं एक्शन सेट करें + अटैचमेंट सेव करें + और + जवाब दें + सभी को जवाब दें + फॉरवर्ड करें + पढ़ा मार्क करें + नहीं पढ़ा मार्क करें + तारा लगाएं + तारा हटाएं + लेबल करें… + ले जाएं… + कचरा + मिटाएं + संग्रह + स्पैम + संदेश को रोशनी मोड में देखें + संदेश को अंधेरे मोड में देखें + प्रिंट करें + हैडर देखें + HTML देखें + फिशिंग रिपोर्ट करें + मुझे याद दिलाना + पीडीएफ के रूप में सहेजें + इस इंसान से भविष्य में जो ईमेल मिलें उसके लिएं एक्शन सेट करें + अटैचमेंट सेव करें + ज़्यादा + एक्शन लोड करने में असफल + कल + टूलबार को अनुकूलित करें + टूलबार को अनुकूलित करें + संदेश जो कचरे में 30 दिन से ज़्यादा से है को अपनेआप मिटाएं। + संदेश जो स्पैम में 30 दिन से ज़्यादा से है को अपनेआप मिटाएं। + 30 दिनों से अधिक समय से ट्रैश या स्पैम में पड़े संदेशों को स्वचालित रूप से हटाने के लिए अपग्रेड करें। + संदेश जो कचरे और स्पैम में 30 दिन से ज़्यादा समय से हैं, उन्हें अपने आप मिटा दिया जाएगा। + एनेबल + नहीं, शुक्रिया + और जानें + + एक्सपायर हो गया + %dd + %dh + %dm + ये संदेश एक घंटे से कम समय में अपनेआप मिट जाएगा + ये संदेश एक दिन से कम समय में अपनेआप मिट जाएगा + ये संदेश %s को अपनेआप मिट जाएगा + + %d मिनट + %d मिनट + + + %d घंटा + %d घंटे + + + %d दिन + %d दिन + + + अनुलग्नक + अनुलग्नक डाउनलोड के सूचनाएं + ईमेल + लॉगइन सूचनाएं + आने वाले ईमेल की सूचनाएं + नए लॉगइन सूचनाएं + आधिकारिक + ये एक्शन करने के लिए कोई ऐप नहीं मिला + क्लिपबोर्ड पर कॉपी किया गया + रद्द करें + मिटाएं + रद्द करें + बैंगनी + एनज़िअन + गुलाबी + बेर + स्ट्रॉबेरी + गुलाबी लाल + गाजर + तांबा + सहारा + मिट्टी + स्लेट नीला + कोबाल्ट + प्रशांत + महासागर + रीफ़ + चीड़ + फर्न + जंगल + जैतुन + आचार + पूर्ववत् करें + कार्रवाई वापस की गई + कार्रवाई वापस करना असफल रहा + diff --git a/mail-common/presentation/src/main/res/values-hr/strings.xml b/mail-common/presentation/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..2f6ac4fc07 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-hr/strings.xml @@ -0,0 +1,144 @@ + + + + Pokušaj ponovno + Povratak na prethodni ekran + Niste prijavljeni + Odgovori + Odgovori svima + Proslijedi + Odgovori + Odgovori svima + Proslijedi + Označi kao pročitano + Označi kao nepročitano + Zvjezdica + Ukloni zvjezdicu + Označi kao… + Premjesti u… + Premjesti u otpad + Izbriši + Arhiva + Premjesti u neželjenu poštu + Pogledajte poruku u svijetlom načinu rada + Pogledajte poruku u tamnom načinu rada + Ispis + Pregled zaglavlja + Pregledaj HTML + Prijavi krađu identiteta + Podsjeti me + Spremi kao PDF + Postavite radnju za buduće e-poruke ovog pošiljatelja + Spremi privitke + Više + Odgovori + Odgovori svima + Proslijedi + Označi kao pročitano + Označi kao nepročitano + Zvjezdica + Ukloni zvjezdicu + Označi kao… + Premjesti u… + Otpad + Izbriši + Arhiva + Neželjena pošta + Pogledajte poruku u svijetlom načinu rada + Pogledajte poruku u tamnom načinu rada + Ispis + Pregled zaglavlja + Pregledaj HTML + Prijavi krađu identiteta + Podsjeti me + Spremi kao PDF + Postavite radnju za buduće e-poruke ovog pošiljatelja + Spremi privitke + Više + Učitavanje radnji nije uspjelo + Jučer + Customize toolbar + Customize toolbar + Automatically delete messages that have been in trash for more than 30 days. + Automatically delete messages that have been in spam for more than 30 days. + Upgrade to automatically delete messages that have been in trash or spam for more than 30 days. + Messages that have been in trash and spam more than 30 days will be automatically deleted. + Omogući + Ne, hvala + Saznajte više + + Isteklo + %dd + %dh + %dm + This message will be automatically deleted in less than an hour + This message will be automatically deleted in less than a day + This message will be automatically deleted in %s + + %d minute + %d minutes + %d minutes + + + %d hour + %d hours + %d hours + + + %d day + %d days + %d days + + + Privitci + Obavijesti o preuzimanju privitaka + E-pošta + Upozorenja o prijavi + Obavijesti dolazne pošte + Obavijesti o novim prijavama + Službeno + Nije pronađena aplikacija za upravljanje ovom radnjom + Kopirano u međuspremnik + Poništi + Izbriši + Poništi + Ljubičasta + Gorčica + Ružičasta + Šljiva + Jagoda + Trešnja + Mrkva + Bakar + Sahara + Tlo + Škriljevac plava + Kobalt + Pacifik + Ocean + Greben + Bor + Paprat + Šuma + Maslina + Kiseli krastavac + Poništi + Radnja je vraćena + Vraćanje radnje nije uspjelo + diff --git a/mail-common/presentation/src/main/res/values-hu/strings.xml b/mail-common/presentation/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..320829e00b --- /dev/null +++ b/mail-common/presentation/src/main/res/values-hu/strings.xml @@ -0,0 +1,141 @@ + + + + Újra + Vissza az előző képernyőre + Nincs bejelentkezve + Válasz + Válasz mindenkinek + Továbbítás + Válasz + Válasz mindenkinek + Továbbítás + Megjelölés olvasottként + Megjelölés olvasatlanként + Csillagozás + Csillagozás törlése + Címkézés, mint… + Áthelyezés ide… + Áthelyezés a lomtárba + Törlés + Archívum + Áthelyezés a levélszeméthez + Üzenet megtekintése világos módban + Üzenet megtekintése sötét módban + Nyomtatás + Fejlécek megtekintése + HTML megjelenítése + Adathalászat bejelentése + Emlékeztessen + Mentés PDF-ként + A feladó jövőbeli e-mailjeihez tartozó művelet beállítása + Mellékletek mentése + Több + Válasz + Válasz mindenkinek + Továbbítás + Megjelölés olvasottként + Megjelölés olvasatlanként + Csillagozás + Csillagozás törlése + Címkézés, mint… + Áthelyezés ide… + Lomtár + Törlés + Archiválás + Levélszemét + Üzenet megtekintése világos módban + Üzenet megtekintése sötét módban + Nyomtatás + Fejlécek megtekintése + HTML megjelenítése + Adathalászat jelentése + Emlékeztessen + Mentés PDF-ként + A feladó jövőbeli e-mailjeihez tartozó művelet beállítása + Mellékletek mentése + Továbbiak + Töltés sikertelen + Tegnap + Eszköztár testreszabása + Eszköztár testreszabása + A 30 napnál régebben a lomtárba helyezett üzenetek automatikusan törlése kerülnek. + A 30 napnál régebben Levélszemét mappába került üzenetek automatikus törlése. + Váltson nagyobb csomagra a 30 napja lomtárban vagy a levélszemét mappában lévő üzenetek automatikus törléséhez. + A 30 napnál régebben lomtárba vagy a levélszemét mappába került üzenetek automatikusan törlésre kerülnek. + Engedélyezés + Nem, köszi + További információk + + Lejárt + %dd + %dh + %dm + Ez az üzenet kevesebb mint egy órán belül automatikusan törlődik. + Ez az üzenet kevesebb mint egy napon belül automatikusan törlődik. + Ez az üzenet automatikusan törlésre kerül, ekkor: %s + + %d perc + %d perc + + + %d óra + %d óra + + + %d nap + %d nap + + + Mellékletek + Értesítések a melléklet letöltésekhez + E-mailek + Bejelentkezési figyelmeztetések + Bejövő e-mail értesítések + Új bejelentkezési értesítések + Hivatalos + Nincs megfelelő alkalmazás a művelet elvégzésére + Vágólapra másolva + Mégsem + Törlés + Mégsem + Lila + Encián + Rózsaszín + Szilvakék + Eper + Cseresznye + Sárgarépa + Réz + Szahara + Föld + Palakék + Kobalt + Csendes-óceán + Óceán + Zátony + Fenyő + Páfrány + Erdő + Olajzöld + Kovászos uborka + Visszavonás + Művelet visszavonva + Művelet visszavonás hiba + diff --git a/mail-common/presentation/src/main/res/values-in/strings.xml b/mail-common/presentation/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..f48fddc662 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-in/strings.xml @@ -0,0 +1,138 @@ + + + + Coba lagi + Kembali ke layar sebelumnya + Tidak masuk + Balas + Balas semua + Teruskan + Balas + Balas semua + Teruskan + Tandai dibaca + Tandai belum dibaca + Bintangi + Hapus bintang + Labeli sebagai… + Pindahkan ke… + Pindahkan ke sampah + Hapus + Arsipkan + Pindahkan ke spam + Tampilkan pesan dalam mode terang + Tampilkan pesan dalam mode gelap + Cetak + Lihat header + Lihat HTML + Laporkan upaya phishing + Ingatkan saya + Simpan sebagai PDF + Tentukan tindakan untuk email dari pengirim ini ke depannya + Simpan lampiran + Lainnya + Balas + Balas semua + Teruskan + Tandai dibaca + Tandai belum dibaca + Bintangi + Hapus bintang + Labeli sebagai… + Pindahkan ke… + Sampah + Hapus + Arsipkan + Spam + Tampilkan pesan dalam mode terang + Tampilkan pesan dalam mode gelap + Cetak + Lihat header + Lihat HTML + Laporkan phishing + Ingatkan saya + Simpan sebagai PDF + Tentukan tindakan untuk email dari pengirim ini ke depannya + Simpan lampiran + Lainnya + Tindakan gagal dimuat + Kemarin + Sesuaikan toolbar + Sesuaikan toolbar + Hapus pesan yang telah berada ke sampah selama lebih dari 30 hari secara otomatis. + Hapus pesan yang telah ditandai sebagai spam selama lebih dari 30 hari secara otomatis. + Tingkatkan untuk menghapus pesan yang telah berada di sampah dan spam selama lebih dari 30 hari secara otomatis. + Pesan yang telah berada di sampah dan spam selama lebih dari 30 hari akan dihapus secara otomatis. + Aktifkan + Tidak, terima kasih + Pelajari lebih lanjut + + Kedaluwarsa + %d hari + %d j + %d jt + Pesan ini akan dihapus secara otomatis dalam waktu kurang dari satu jam + Pesan ini akan dihapus secara otomatis dalam waktu kurang dari satu hari + Pesan ini akan dihapus secara otomatis dalam %s + + %d menit + + + %d jam + + + %d hari + + + Lampiran + Notifikasi untuk unduhan lampiran + Email + Peringatan Entri Masuk + Notifikasi email masuk + Notifikasi entri masuk baru + Resmi + Tidak ada aplikasi yang ditemukan untuk menangani tindakan ini + Disalin ke papan klip + Batalkan + Hapus + Batal + Ungu + Enzian + Merah jambu + Plum + Arbei + Ceri + Wortel + Tembaga + Sahara + Tanah + Biru keabuan + Kobalt + Pasifik + Samudera + Karang + Pinus + Perdu + Hutan + Zaitun + Acar + Urungkan + Tindakan diurungkan + Tindakan gagal diurungkan + diff --git a/mail-common/presentation/src/main/res/values-it/strings.xml b/mail-common/presentation/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..6eb54a06c6 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-it/strings.xml @@ -0,0 +1,141 @@ + + + + Riprova + Torna alla schermata precedente + Accesso non effettuato + Rispondi + Rispondi a tutti + Inoltra + Rispondi + Rispondi a tutti + Inoltra + Segna come letto + Segna come non letto + Segna come importante + Segna come non importante + Etichetta come… + Sposta in… + Sposta nel cestino + Elimina + Sposta nell\'archivio + Sposta in spam + Visualizza in modalità chiara + Visualizza in modalità scura + Stampa + Visualizza le intestazioni + Visualizza HTML + Segnala come phishing + Ricordamelo + Salva come PDF + Imposta un\'azione per tutte le email future provenienti da questo mittente + Salva gli allegati + Altro + Rispondi + Rispondi a tutti + Inoltra + Segna come letto + Segna come non letto + Segna come importante + Segna come non importante + Etichetta come… + Sposta in… + Sposta nel cestino + Elimina + Sposta nell\'archivio + Sposta in spam + Visualizza in modalità chiara + Visualizza in modalità scura + Stampa + Visualizza le intestazioni + Visualizza HTML + Segnala come phishing + Ricordamelo + Salva come PDF + Imposta un\'azione per tutte le email future provenienti da questo mittente + Salva gli allegati + Altro + Caricamento delle azioni non riuscito + Ieri + Barra degli strumenti + Barra degli strumenti + Elimina automaticamente i messaggi che sono rimasti nel cestino per oltre 30 giorni + Elimina automaticamente i messaggi che sono rimasti nella cartella spam per oltre 30 giorni + Passa a un piano a pagamento per eliminare automaticamente i messaggi che sono rimasti nel cestino o nella cartella spam per oltre 30 giorni + I messaggi che sono rimasti nel cestino o nella cartella spam per oltre 30 giorni verranno eliminati + Abilita + Annulla + Scopri di più + + Scaduto + %d g + %d h + %d min + Questo messaggio verrà eliminato automaticamente entro un\'ora + Questo messaggio verrà eliminato automaticamente entro un giorno + Questo messaggio verrà eliminato automaticamente tra %s + + %d minuto + %d minuti + + + %d ora + %d ore + + + %d giorno + %d giorni + + + Allegati + Stato dello scaricamento degli allegati + Messaggi + Avvisi di accesso + Nuovi messaggi nelle caselle di posta + Nuovi accessi agli account + Ufficiale + Nessuna applicazione compatibile trovata. Azione annullata. + Copiato negli appunti + Annulla + Elimina + Annulla + Viola + Genziana + Rosa + Prugna + Fragola + Ciliegia + Carota + Rame + Sahara + Terra + Ardesia + Cobalto + Pacifico + Oceano + Corallo + Pino + Felce + Foresta + Oliva + Cetriolo + Annulla + Azione annullata + Annullamento dell\'azione non riuscito + diff --git a/mail-common/presentation/src/main/res/values-ja/strings.xml b/mail-common/presentation/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..ded7f2dd13 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ja/strings.xml @@ -0,0 +1,138 @@ + + + + 再試行 + 前の画面に戻る + サインインしていません + 返信 + 全員に返信 + 転送 + 返信 + 全員に返信 + 転送 + 既読にする + 未読にする + スターを付ける + スターを外す + ラベルを付ける… + 移動… + ごみ箱に移動 + 削除 + アーカイブ + 迷惑メールに移動 + ライトモードでメールをを表示 + ダークモードでメールをを表示 + 印刷 + ヘッダーを表示する + HTMLで表示する + フィッシングを報告 + 後で通知 + PDFとして保存 + この送信者からのメールに既定の動作を設定 + 添付ファイルを保存 + もっと見る + 返信 + 全員に返信 + 転送 + 既読にする + 未読に戻す + スターを付ける + スターを外す + ラベルを付ける… + 移動… + ごみ箱に移動 + 削除 + アーカイブ + 迷惑メール + ライトモードでメールを表示 + ダークモードでメールを表示 + 印刷 + ヘッダーを表示 + HTMLを表示 + フィッシング詐欺を報告 + 後で通知 + PDFとして保存 + この送信者からのメールに既定の動作を設定 + 添付ファイルを保存 + もっと見る + 動作の読み込みに失敗 + 昨日 + ツールバーのカスタマイズ + ツールバーのカスタマイズ + ゴミ箱へ移動したメッセージを 30 日後に自動的に削除する。 + 迷惑メールへ移動したメッセージを 30 日後に自動的に削除する。 + ゴミ箱・迷惑メールへ移動したメッセージを 30 日後に自動的に削除するには、アップグレードしましょう。 + ゴミ箱・迷惑メールへ移動したメッセージは 30 日後に自動的に削除されます。 + 有効化 + 有効化しない + もっと詳しく + + 期限切れ + %d日 + %d 時間 + %d分 + このメッセージは一時間以内に自動的に削除されます + このメッセージは1日以内に自動的に削除されます + このメッセージは%s日で自動的に削除されます + + %d 分 + + + %d 時間 + + + %d 日 + + + 添付ファイル + 添付ファイルのダウンロードに関する通知 + メール + ログインアラート + 受信メール通知 + 新しいログイン通知 + 公式 + この操作を処理するアプリが見つかりませんでした。 + クリップボードにコピーされました + キャンセル + 削除 + キャンセル + パープル + リンドウ + ピンク + プラム + ストロベリー + セリーズ + キャロット + カッパー + サハラ + ソイル + スレートブルー + コバルト + パシフィック + オーシャン + リーフ + パイン + シダ + フォレスト + オリーブ + ピクルス + 取り消し + 操作を取り消しました + 操作の取り消しに失敗しました + diff --git a/mail-common/presentation/src/main/res/values-ka/strings.xml b/mail-common/presentation/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..60e6e318a8 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ka/strings.xml @@ -0,0 +1,141 @@ + + + + თავიდან + წინა ეკრანზე გადასვლა + შესული არ ბრძანდებით + პასუხი + ყველასთვის პასუხი + გადაგზავნა + პასუხი + ყველასთვის პასუხი + გადაგზავნა + წაკითხულად მონიშვნა + წაუკითხავად მონიშვნა + ვარსკვლავი + ვარსკვლავის მოხსნა + ჭდის დადება… + გადატანა… + ნაგვის ყუთში გადატანა + წაშლა + დაარქივება + სპამში გადატანა + შეტყობინებების ღია რეჟიმში ნახვა + შეტყობინებების ბნელ რეჟიმში ნახვა + დაბეჭდვა + თავსართების ნახვა + HTML-ის ნახვა + ფიშინგის შესახებ შეტყობინება + შემახსენე + PDF-ად შენახვა + მომავალში ამ გამომგზავნიდან მოსული ელფოსტებზე გადატარებული ქმეების დაყენება + მიმაგრებული ფაილების შენახვა + მეტი + პასუხი + ყველასთვის პასუხი + გადაგზავნა + წაკითხულად მონიშვნა + წაუკითხავად მონიშვნა + ვარსკვლავი + ვარსკვლავის მოხსნა + ჭდის დადება… + გადატანა… + სანაგვე ყუთი + წაშლა + არქივი + სპამი + შეტყობინებების ღია რეჟიმში ნახვა + შეტყობინებების ბნელ რეჟიმში ნახვა + დაბეჭდვა + თავსართების ნახვა + HTML-ის ნახვა + ფიშინგის შესახებ შეტყობინება + შემახსენე + PDF-ად შენახვა + მომავალში ამ გამომგზავნიდან მოსული ელფოსტებზე გადატარებული ქმეების დაყენება + მიმაგრებული ფაილების შენახვა + მეტი + ქმედებების ჩატვირთვის შეცდომა + გუშინ + ხელსაწყოების ზოლის მორგება + ხელსაწყოების ზოლის მორგება + ნაგვის საქაღალდეში მყოფი შეტყობინებების ავტომატური წაშლა 30 დღეში. + სპამის საქაღალდეში მყოფი შეტყობინებების ავტომატური წაშლა 30 დღეში. + ნაგვის ყუთში მყოფი შეტყობინებების და სპამის 30 დღეში ავტომატური წაშლისთვის აწიეთ სატარიფო გეგმა. + ნაგვის ყუთში 30 დღეზე მეტ ხანს მყოფი შეტყობინებები და სპამი ავტომატურად წაიშლება. + ჩართვა + არა, გმადლობთ + გაიგეთ მეტი + + ვადაგასულია + %dd + %dh + %dm + ეს შეტყობინება ავტომატურად წიაშლება საათზე ნაკლებ დროში + ეს შეტყობინება ავტომატურად წიაშლება დღეზე ნაკლებ დროში + ამ შეტყობინების ავტომატური წაიშლება %s-ში + + %d წუთი + %d წუთი + + + %d საათი + %d საათი + + + %d დღე + %d დღე + + + მიმაგრებები + მიმაგრებული ფაილების გადმოწერის გაფრთხილება + ელფოსტები + შესვლის გაფრთხლებები + შემომავალი ელფოსტის გაფრთხილებები + ახალი შესვლის გაფრთხილებები + ოფიციალური + ვერ ვიპოვე ამ ქმედების გამგები აპი + დაკოპირდა გაცვლის ბაფერში + გაუქმება + წაშლა + გაუქმება + იასამნისფერი + ნაღვლისფერი + ვარდისფერი + ქლიავისფერი + მარწყვისფერი + ღია ალუბლისფერი + სტაფილოსფერი + სპილენძისფერი + უდაბნოსფერი + მიწისფერი + მოლურჯო-მონაცრისფრო + კაშკაშა ლურჯი + წყნარი ოკეანის + ზღვისფერი + მარჯნისფერი + ფიჭვისფერი + გვიმრისფერი + ტყისფერი + ზეთისხილისფერი + კიტრი + დაბრუნება + მოქმედება დაბრუნებულია + ქმედების დაბრუნება ჩავარდა + diff --git a/mail-common/presentation/src/main/res/values-kab/strings.xml b/mail-common/presentation/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..429be7d3d6 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-kab/strings.xml @@ -0,0 +1,141 @@ + + + + Ɛref̣ tikelt-nniḍen + Uɣal ɣer ugdil udfir + Ur iqqin ara + Err + Err-asen akk + Ɣer zdat + Err + Err-asen akk + Ɣer zdat + Creḍ yettwaɣra + Creḍ ur yettwaɣra ara + Itri + Kkes seg inurifen + Bzem am… + Mutti ɣer… + Awi ar iḍumman + Kkes + Tarcivt + Awi ɣer yispamen + Wali izen deg uskar aceɛlal + Wali izen deg uskar ubrik + Siggez + Sken iqeṛṛa + Wali HTML + Azen aneqqis phishing + Smekti-yi-d + Sekles am PDF + Sbadu tigawt i yimaylen imaynuten n umazan-a + Sekles imeddayen + Ugar + Err + Err-asen akk + Welleh + Creḍ yettwaɣra + Creḍ ur yettwaɣra ara + Itri + Kkes seg yinurifen + Bzem am… + Mutti ɣer… + Taqecwalt + Kkes + Taṛcivt + Aspam + Wali izen deg uskar aceɛlal + Wali izen deg uskar ubrik + Siggez + Sken iqeṛṛa + Wali HTML + Azen aneqqis phishing + Smekti-yi-d + Sekles am PDF + Sbadu tigawt i yimaylen imaynuten n umazan-a + Sekles imeddayen + Ugar + Asali n tigawin yecceḍ + Iḍelli + Sagen afeggag n yifecka + Sagen afeggag n yifecka + Kkes iznan s wudem awurman i yellan deg tqecwalt neɣ deg uspam i wugar n 30 wussan. + Kkes iznan yellan deg spam akteṛ n 30 wussan s timmadî. + Sali aɣawas-ik i tukksa n yiznan s wudem awurman i yellan deg tqecwalt neɣ deg uspam i wugar n 30 wussan. + Iznan s wudem awurman i yellan deg tqecwalt neɣ deg uspam i wugar n 30 wussan, ad ttwakksen s wudem awurman. + Rmed + Uhu, tanemmirt + Issin ugar + + Yemmut + %dass + %dssaɛa + %dsdt + Izen-a ad yettwakkes weḥd-s deg lqell n ssaɛa + Izen-a ad yettwakkes weḥd-s deg wass + Izen-a ad yettwakkes s wudem awurman deg %s + + %d n tesdat + %d n tesdatin + + + %d n usrag + %d n yisragen + + + %d n wass + %d n wussan + + + Imeddayen + Ilɣa i yisadaren n umedday + Imaylen + Login Alerts + Ilɣa n yimayl d-ikecmen + Alɣu amaynut n tuqqna + Unṣib + Ulac asnas yettwafen akken ad isefrek tigawt-agi + Nɣel ɣef wafus + Sefsex + Kkes + Sefsex + Akeẓẓal + Enzian + Axuxi + Taxuxet + Tafrazt + Areḍli + Ẓrudiyya + Nneḥḥas + Taneẓruft + Akal + Slate blue + Kubalt + Pacific + Agaraw + Lmerǧan + Azumba + Ifilku + Tiẓgi + Azemmur + Aɣrum + Sefsex + Tigawt tettwasefsex + Tigawt tegguma ad tettwasefsax + diff --git a/mail-common/presentation/src/main/res/values-ko/strings.xml b/mail-common/presentation/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..29a1bc76ee --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ko/strings.xml @@ -0,0 +1,138 @@ + + + + 재시도 + 이전 화면으로 돌아가기 + 로그인하지 않음 + 회신 + 전체 회신 + 전달 + 답장 + 전체 답장 + 전달 + 읽음으로 표시 + 읽지 않음으로 표시 + 별표 + 별표 제거 + 라벨… + 이동… + 휴지통으로 이동하기 + 삭제 + 보관 편지함 + 스팸으로 이동 + 밝은 테마로 보기 + 어두운 테마로 보기 + 인쇄 + 헤더 보기 + HTML 보기 + 피싱 신고 + 다시 알림 + PDF로 저장 + 이 발신자의 향후 이메일에 대한 작업 설정 + 첨부 파일 저장 + 더보기 + 답장 + 전체 답장 + 전달 + 읽음으로 표시 + 읽지 않음으로 표시 + 별표 표시 + 별표 삭제 + 라벨… + 이동… + 휴지통 + 삭제 + 보관함 + 스팸함 + 밝은 테마로 보기 + 어두운 테마로 보기 + 인쇄 + 헤더 보기 + HTML 보기 + 피싱 신고 + 다시 알림 + PDF로 저장 + 이 발신자의 향후 이메일에 대한 작업 설정 + 첨부 파일 저장 + 더 보기 + 동작을 불러오는 데 실패했습니다 + 어제 + 도구바 사용자화 + 도구바 사용자화 + 30일 이상 휴지통에 있는 메시지를 자동으로 삭제합니다. + 30일 이상 스팸함에 있던 메시지를 자동으로 삭제합니다. + 휴지통 및 스팸함에 30일 이상 방치된 메시지들을 자동으로 삭제하려면 업그레이드하세요. + 휴지통 및 스팸함으로 이동된 메시지가 30일 이후 자동으로 삭제됩니다. + 활성화 + 아니요, 괜찮아요 + 더 알아보기 + + 만료됨 + %d일 + %d시간 + %d분 + 이 메시지가 한 시간 이내로 자동 삭제됩니다. + 이 메시지가 하루 내로 자동 삭제됩니다. + 메시지가 %s에 자동으로 삭제됨 + + %d분 + + + %d시간 + + + %d일 + + + 첨부파일 + 첨부파일 다운로드 알림 + 이메일 + 로그인 알림 + 수신 이메일 알림 + 새로운 로그인 알림 + 공식 + 이 작업을 처리하는 앱을 찾을 수 없습니다 + 클립보드로 복사함 + 취소 + 삭제 + 취소 + 퍼플 + 엔지안 + 핑크 + 플럼 + 스트로베리 + 세리즈 + 캐럿 + 코퍼 + 사하라 + 소일 + 슬레이트 블루 + 코발트 + 퍼시픽 + 오션 + 리프 + 파인 + + 포레스트 + 올리브 + 피클 + 실행 취소 + 동작 되돌림 + 동작 되돌리기 실패 + diff --git a/mail-common/presentation/src/main/res/values-nb-rNO/strings.xml b/mail-common/presentation/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..c1d73db476 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,141 @@ + + + + Prøv på nytt + Tilbake til forrige skjerm + Ikke pålogget + Svar + Svar alle + Videresend + Svar + Svar alle + Videresend + Merk som lest + Merk som ulest + Stjernemerk + Fjern stjernemerking + Merk som... + Flytt til… + Flytt til papirkurven + Slett + Arkiver + Flytt til søppelpost + Vis melding i lys modus + Vis melding i mørk modus + Skriv ut + Vis overskrifter + Vis HTML + Rapportér nettfisking + Påminn meg + Lagre som PDF + Velg handling for fremtidige e-poster fra denne avsenderen. + Lagre vedlegg + Mer + Svar + Svar alle + Videresend + Merk som lest + Merk som ulest + Stjernemerk + Fjern stjernemerking + Merk som... + Flytt til… + Papirkurv + Slett + Arkiver + Søppelpost + Vis melding i lys modus + Vis melding i mørk modus + Skriv ut + Vis overskrifter + Vis HTML + Rapportér nettfisking + Påminn meg + Lagre som PDF + Velg handling for fremtidige e-poster fra denne avsenderen. + Lagre vedlegg + Mer + Feil ved henting av handlinger + I går + Tilpass verktøylinjen + Tilpass verktøylinjen + Slett automatisk meldinger som har ligget i papirkurven i mer enn 30 dager. + Slett automatisk meldinger som har ligget i søppelpostmappen i mer enn 30 dager. + Oppgrader for å få automatisk sletting av meldinger som har ligget i papirkurven eller søppelposten i mer enn 30 dager. + Meldinger i papirkurven eller søppelposten som har vært der lenger enn 30 dager, slettes automatisk. + Aktiver + Nei takk + Lær mer + + Utløpt + %d d + %d t + %d m + Denne meldingen slettes automatisk om mindre enn en time + Denne meldingen slettes automatisk om mindre enn en dag + Denne meldingen slettes automatisk om %s + + %d minutt + %d minutter + + + %d time + %d timer + + + %d dag + %d dager + + + Vedlegg + Varsler for nedlasting av vedlegg + E-poster + Varsler om pålogging + Inkommende e-postvarsler + Varsler om nye pålogginger + Offisiell + Ingen app ble funnet til å håndtere denne handlingen. + Kopiert til utklippstavlen + Avbryt + Slett + Avbryt + Lilla + Enzian blå + Rosa + Plomme + Jordbær + Kirsebærrød + Gulrot + Kobberfarget + Sahara + Jord + Skifer blå + Kobolt + Stillehavet + Hav + Rev + Furu + Bregne + Skog + Oliven + Sylteagurk + Angre + Handling tilbakestilt + Tilbakestilling av handling mislyktes + diff --git a/mail-common/presentation/src/main/res/values-nl/strings.xml b/mail-common/presentation/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..c8ce2a5dc7 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-nl/strings.xml @@ -0,0 +1,141 @@ + + + + Opnieuw proberen + Terug naar vorig scherm + Niet ingelogd + Beantwoorden + Allen beantwoorden + Doorsturen + Beantwoorden + Allen beantwoorden + Doorsturen + Markeer als gelezen + Markeer als ongelezen + Ster + Ster verwijderen + Label als… + Verplaatsen naar… + Verplaats naar prullenbak + Verwijderen + Archief + Verplaatsen naar spam + Bericht bekijken in lichte modus + Bericht bekijken in donkere modus + Afdrukken + Headers bekijken + Bekijk HTML + Phishing rapporteren + Herinner mij + Bewaar als PDF + Actie instellen voor toekomstige e-mails van deze afzender + Bijlagen opslaan + Meer + Beantwoorden + Allen beantwoorden + Doorsturen + Markeer als gelezen + Markeer als ongelezen + Ster + Ster verwijderen + Label als… + Verplaatsen naar… + Prullenbak + Verwijderen + Archiveren + Spam + Bericht bekijken in lichte modus + Bericht bekijken in donkere modus + Afdrukken + Headers bekijken + Bekijk HTML + Phishing rapporteren + Herinner mij + Opslaan als PDF + Actie instellen voor toekomstige e-mails van deze afzender + Bijlagen opslaan + Meer + Acties laden mislukt + Gisteren + Werkbalk aanpassen + Werkbalk aanpassen + Automatisch berichten verwijderen die al 30 of meer dagen in de prullenbak zitten. + Automatisch berichten verwijderen die al 30 of meer dagen in de spam zitten. + Upgrade om automatisch berichten te verwijderen die meer dan 30 dagen in de prullenbak of spam zitten. + Berichten die meer dan 30 dagen in de prullenbak en spam zitten worden automatisch verwijderd. + Inschakelen + Nee, bedankt + Meer info + + Verlopen + %dd + %du + %dm + Dit bericht wordt automatisch verwijderd in minder dan een uur + Dit bericht wordt automatisch verwijderd in minder dan een dag + Dit bericht wordt automatisch verwijderd in %s + + %d minuut + %d minuten + + + %d uur + %d uren + + + %d dag + %d dagen + + + Bijlagen + Meldingen voor downloads van bijlagen + E-mails + Login Waarschuwingen + Binnenkomende e-mailnotificaties + Nieuwe inlogmeldingen + Officieel + Er is geen app gevonden om deze actie uit te voeren + Gekopieerd naar het klembord + Annuleren + Verwijderen + Annuleren + Paars + Gentiaan + Roze + Pruim + Aardbei + Kers + Wortel + Koper + Sahara + Aarde + Leiblauw + Kobalt + Stille oceaan + Oceaan + Rif + Den + Varen + Bos + Olijf + Augurk + Ongedaan maken + Actie ongedaan gemaakt + Actie ongedaan maken mislukt + diff --git a/mail-common/presentation/src/main/res/values-pl/strings.xml b/mail-common/presentation/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..61de0ee614 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-pl/strings.xml @@ -0,0 +1,147 @@ + + + + Spróbuj ponownie + Powrót do poprzedniego ekranu + Niezalogowano + Odpowiedz + Odpowiedz wszystkim + Przekaż dalej + Odpowiedz + Odpowiedz wszystkim + Przekaż dalej + Oznacz jako przeczytane + Oznacz jako nieprzeczytane + Oznacz gwiazdką + Usuń gwiazdkę + Oznacz jako… + Przenieś do… + Przenieś do kosza + Usuń + Archiwizuj + Przenieś do spamu + Pokaż wiadomość w jasnym motywie + Pokaż wiadomość w ciemnym motywie + Drukuj + Pokaż nagłówki + Widok HTML + Zgłoś próbę wyłudzenia informacji + Przypomnij + Zapisz jako plik PDF + Ustaw akcję dla przyszłych wiadomości od tego nadawcy + Zapisz załączniki + Więcej + Odpowiedz + Odpowiedz wszystkim + Przekaż dalej + Oznacz jako przeczytane + Oznacz jako nieprzeczytane + Oznacz gwiazdką + Usuń gwiazdkę + Oznacz jako… + Przenieś do… + Przenieś do kosza + Usuń + Archiwizuj + Spam + Pokaż wiadomość w jasnym motywie + Pokaż wiadomość w ciemnym motywie + Drukuj + Pokaż nagłówki + Widok HTML + Zgłoś próbę wyłudzenia informacji + Przypomnij + Zapisz jako plik PDF + Ustaw akcję dla przyszłych wiadomości od tego nadawcy + Zapisz załączniki + Więcej + Ładowanie akcji nie powiodło się + Wczoraj + Dostosuj pasek narzędzi + Dostosuj pasek narzędzi + Automatycznie usuwaj wiadomości, które znajdują się w koszu dłużej niż 30 dni. + Automatycznie usuwaj wiadomości, które znajdują się w spamie dłużej niż 30 dni. + Ulepsz konto, aby automatycznie usuwać wiadomości, które znajdują się w koszu lub spamie dłużej niż 30 dni. + Wiadomości, które znajdują się w koszu lub spamie dłużej niż 30 dni, zostaną automatycznie usunięte. + Włącz + Nie, dziękuję + Dowiedz się więcej + + Wygasła + %d d. + %d godz. + %d min + Wiadomość zostanie automatycznie usunięta za mniej niż godzinę + Wiadomość zostanie automatycznie usunięta za mniej niż 24 godziny + Wiadomość zostanie automatycznie usunięta za %s + + %d minutę + %d minut + %d minut + %d minut + + + %d godzinę + %d godziny + %d godzin + %d godzin + + + %d dzień + %d dnia + %d dni + %d dni + + + Załączniki + Powiadomienia o pobieraniu załączników + Wiadomości + Alerty logowania + Powiadomienia wiadomości przychodzących + Powiadomienia o nowych logowaniach + Oficjalna + Nie znaleziono aplikacji do obsługi tego działania + Skopiowano do schowka + Anuluj + Usuń + Anuluj + Fioletowy + Atramentowy + Różowy + Śliwkowy + Truskawkowy + Wiśniowy + Marchewkowy + Miedziany + Sahara + Ziemisty + Slate blue + Kobaltowy + Pacyficzny + Morski + Rafa koralowa + Sosnowy + Paprotkowy + Leśny + Oliwkowy + Khaki + Cofnij + Akcja została cofnięta + Cofnięcie akcji nie powiodło się + diff --git a/mail-common/presentation/src/main/res/values-pt-rBR/strings.xml b/mail-common/presentation/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..5492be97c4 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,141 @@ + + + + Tentar novamente + Voltar para a tela anterior + Sessão não iniciada + Responder + Responder a todos + Encaminhar + Responder + Responder a todos + Encaminhar + Marcar como lida + Marcar como não lida + Favorito + Remover favorito + Marcar como… + Mover para… + Mover para a lixeira + Excluir + Arquivo + Mover para spam + Ver mensagem no modo claro + Ver mensagem no modo escuro + Imprimir + Visualizar cabeçalhos + Ver HTML + Reportar como phishing + Lembre-me + Salvar como PDF + Definir ações para futuros e-mails deste remetente + Salvar anexos + Mais + Responder + Responder a todos + Encaminhar + Marcar como lida + Marcar como não lida + Favoritar + Remover dos favoritos + Marcar como… + Mover para… + Lixeira + Excluir + Arquivo + Spam + Ver mensagem no modo claro + Ver mensagem no modo escuro + Imprimir + Visualizar cabeçalhos + Ver HTML + Reportar como phishing + Lembre-me + Salvar como PDF + Definir ações para futuros e-mails deste remetente + Salvar anexos + Mais + Erro ao carregar ações + Ontem + Personalizar barra de ferramentas + Personalizar barra de ferramentas + Exclua automaticamente mensagens que estejam na lixeira por mais de 30 dias. + Exclua automaticamente mensagens que estejam na caixa de spam por mais de 30 dias. + Faça upgrade para excluir automaticamente mensagens que estão na lixeira ou no spam por mais de 30 dias. + Mensagens que estiverem na lixeira e no spam por mais de 30 dias serão excluídas automaticamente. + Ativar + Não, obrigado + Saiba mais + + Expirado + %d d + %d h + %d min + Esta mensagem será automaticamente excluída em menos de uma hora + Esta mensagem será automaticamente excluída em menos de um dia + Esta mensagem será excluída automaticamente em %s + + %d minuto + %d minutos + + + %d hora + %d horas + + + %d dia + %d dias + + + Anexos + Notificação para downloads de anexos + E-mails + Alertas de início de sessão + Notificações de e-mail recebidos + Notificações de início de sessão + Oficial + Nenhum aplicativo foi encontrado para executar essa ação + Copiado para a área de transferência + Cancelar + Excluir + Cancelar + Roxo + Enzian + Rosa + Ameixa + Morango + Cereja + Cenoura + Cobre + Saara + Terra + Azul-ardósia + Cobalto + Pacífico + Oceano + Recife + Pinheiro + Samambaia + Floresta + Oliva + Salmoura + Desfazer + Ação revertida + Falha ao reverter a ação + diff --git a/mail-common/presentation/src/main/res/values-pt-rPT/strings.xml b/mail-common/presentation/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..767f5e0b67 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,141 @@ + + + + Tentar novamente + Voltar ao ecrã anterior + Sem sessão iniciada + Responder + Responder a todos + Reencaminhar + Responder + Responder a todos + Reencaminhar + Marcar como lida + Marcar como não lida + Estrela + Remover estrela + Etiquetar como… + Mover para… + Mover para o lixo + Eliminar + Arquivar + Mover para o spam + Ver mensagem no modo claro + Ver mensagem no modo escuro + Imprimir + Ver cabeçalhos + Ver HTML + Reportar phishing + Lembre-me + Guardar como PDF + Definir a ação para os futuros e-mails deste remetente + Guardar anexos + Mais + Responder + Responder a todos + Encaminhar + Marcar como lida + Marcar como não lida + Estrela + Remover a estrela + Etiquetar como… + Mover para… + Lixo + Eliminar + Arquivo + Spam + Ver a mensagem no modo claro + Ver a mensagem no modo escuro + Imprimir + Ver os cabeçalhos + Ver o código HTML + Reportar como phishing + Lembre-me + Guardar como PDF + Definir a acção para os futuros e-mails deste remetente + Guardar os anexos + Mais + Erro ao carregar as acções + Ontem + Personalizar a barra de ferramentas + Personalizar a barra de ferramentas + Apagar automaticamente mensagens que estejam no lixo há mais de 30 dias. + Apagar automaticamente mensagens que estejam no spam há mais de 30 dias. + Atualize para poder apagar automaticamente as mensagens que estão no lixo há mais de 30 dias. + As mensagens que estiverem no lixo e no spam há mais de 30 dias serão automaticamente eliminadas. + Ativar + Não, obrigado + Saiba mais + + Expirado + %d d + %d h + %d m + Esta mensagem será apagada automaticamente em menos de uma hora. + Esta mensagem será apagada automaticamente em ${ shortDateMessage } + Esta mensagem será apagada automaticamente em %s. + + %d minuto + %d minutos + + + %d hora + %d horas + + + %d dia + %d dias + + + Anexos + Notificações para as transferências de anexos + E-mails + Alertas de login + Notificações de e-mail recebido + Novas notificações de início de sessão + Oficial + Nenhuma aplicação encontrada para gerir a operação + Copiado para a área de transferência + Cancelar + Eliminar + Cancelar + Púrpura + Enzian + Cor-de-Rosa + Ameixa + Morango + Cereja + Cenoura + Cobre + Saara + Solo + Azul-ardósia + Cobalto + Pacífico + Oceano + Recife + Pinho + Samambaia + Floresta + Azeitona + Pickle + Anular + Ação revertida + Reversão da ação falhou + diff --git a/mail-common/presentation/src/main/res/values-ro/strings.xml b/mail-common/presentation/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..e08b04bd01 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ro/strings.xml @@ -0,0 +1,144 @@ + + + + Reîncercare + Înapoi la ecranul anterior + Neconectat + Răspuns + Răspuns tuturor + Redirecționare + Răspuns + Răspuns tuturor + Redirecționare + Marcare ca citit + Marcare ca necitit + Cu stea + Fără stea + Etichetare ca… + Mutare în… + Mutare în gunoi + Ștergere + Arhivare + Mutare în «Nedorite» + Vizualizare mesaj pe fond deschis + Vizualizare mesaj pe fond întunecat + Tipărire + Afișare antete + Afișare HTML + Raportare înșelăciune + Aminitește-mi + Salvare ca PDF + Setare acțiuni mesaje ulterioare de la acest expeditor + Salvare atașamente + Mai multe + Răspuns + Răspuns tuturor + Redirecționare + Marcare ca citit + Marcare ca necitit + Stea + Fără stea + Etichetare ca… + Mutare în… + Gunoi + Ștergere + Arhivare + Nedorite + Vizualizare mesaj pe fond deschis + Vizualizare mesaj pe fond întunecat + Tipărire + Afișare antete + Afișare HTML + Raportare înșelăciune + Aminitește-mi + Salvare ca PDF + Setare acțiuni mesaje ulterioare de la acest expeditor + Salvare atașamente + Detalii + Încărcarea acțiunilor a eșuat. + Ieri + Personalizare bară de unelte + Personalizare bară de unelte + Ștergere automată mesaje din gunoi mai vechi de 30 de zile. + Ștergere automată mesaje din folderul «Nedorite» mai vechi de 30 de zile. + Modificați-vă planul pentru a putea șterge automat mesajele care au stat în folderul «Gunoi» și «Nedorite» mai mult de 30 de zile. + Mesajele care au stat în folderul «Gunoi» și «Nedorite» mai mult de 30 de zile vor fi șterse automat. + Activare + Nu, mulțumesc + Detalii + + Expirată + %d z + %d h + %d m + Acest mesaj va fi șters automat peste o oră. + Acest mesaj va fi șters automat în mai puțin de o zi. + Acest mesaj va fi șters automat peste %s. + + %d minut + %d minute + %d de minute + + + %d oră + %d ore + %d de ore + + + %d zi + %d zile + %d de zile + + + Fișiere atașate + Notificări pentru descărcări atașamente + Mesaje + Alerte de autentificare + Notificări mesaje primite + Notificări conectări noi + Oficial + Nu există vreo aplicație asociată acestei acțiuni. + Copiat în memorie. + Anulare + Ștergere + Anulare + Violet + Gențiană + Roz + Prună + Căpșună + Cireșiu + Morcov + Cupru + Sahara + Pământiu + Ardezie albastră + Cobalt + Pacific + Ocean + Recif + Pin + Ferigă + Pădure + Măsliniu + Murături + Refacere + Acțiune inversată + Acțiunea de refacere a eșuat. + diff --git a/mail-common/presentation/src/main/res/values-ru/strings.xml b/mail-common/presentation/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..105640979e --- /dev/null +++ b/mail-common/presentation/src/main/res/values-ru/strings.xml @@ -0,0 +1,147 @@ + + + + Повторить + Обратно к предыдущему экрану + Вход не выполнен + Ответить + Ответить всем + Переслать + Ответить + Ответить всем + Переслать + Отметить как прочитанное + Отметить как непрочитанное + Отметить звёздочкой + Снять звёздочку + Отметить как… + Переместить в… + Переместить в Корзину + Удалить + Архив + Переместить в Спам + Посмотреть сообщение в светлой теме + Посмотреть сообщение в тёмной теме + Печать + Просмотреть заголовки + Просмотр HTML + Пожаловаться на фишинг + Напомнить + Сохранить как PDF + Установить действие для последующих писем от этого отправителя + Сохранить вложения + Ещё + Ответить + Ответить всем + Переслать + Отметить как прочитанное + Отметить как непрочитанное + Важное + Снять звёздочку + Отметить как… + Переместить в… + Корзина + Удалить + Архивировать + Спам + Посмотреть сообщение в светлом режиме + Посмотреть сообщение в тёмном режиме + Распечатать + Посмотреть заголовки + Просмотреть HTML + Сообщить о фишинге + Напомнить + Сохранить как PDF + Установить действие для последующих писем от этого отправителя + Сохранить вложения + Ещё + Неудачные действия при скачивании + Вчера + Настроить панель инструментов + Настроить панель инструментов + Автоматически удалять сообщения, находящиеся в корзине более 30 дней. + Автоматически удалять сообщения, находящиеся в спаме более 30 дней. + Повысьте тариф для автоматического удаления сообщений, находящихся в корзине или спаме более 30 дней. + Сообщения, находящиеся в корзине и спаме более 30 дней, будут автоматически удалены. + Включить + Нет, спасибо + Узнать больше + + Срок действия истёк + %d дн. + %d ч + %d мин + Это сообщение будет автоматически удалено менее чем через час + Это сообщение будет автоматически удалено менее чем через день + Это сообщение будет автоматически удалено через %s + + %d минута + %d минуты + %d минут + %d минуты + + + %d час + %d часа + %d часов + %d часов + + + %d день + %d дня + %d дней + %d дня + + + Вложения + Уведомления о скачивании вложений + Электронные письма + Оповещения о входе в систему + Уведомления о входящей электронной почте + Уведомления о новых входах в систему + Официальный + Не найдено ни одного приложения для обработки данного действия + Скопировано в буфер обмена + Отменить + Удалить + Отменить + Фиолетовый + Энзианский + Розовый + Сливовый + Клубничный + Вишнёвый + Морковный + Медный + Сахара + Землистый + Синевато-серый + Кобальтовый + Тихоокеанский + Океанический + Коралловый + Сосновый + Папоротниковый + Лесной + Оливковый + Огуречный + Отменить + Действие отменено + Не удалось отменить действие + diff --git a/mail-common/presentation/src/main/res/values-sk/strings.xml b/mail-common/presentation/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..49716c146a --- /dev/null +++ b/mail-common/presentation/src/main/res/values-sk/strings.xml @@ -0,0 +1,147 @@ + + + + Skúsiť znova + Späť na predošlú obrazovku + Nie ste prihlásený + Odpovedať + Odpovedať všetkým + Preposlať + Odpovedať + Odpovedať všetkým + Preposlať + Označiť ako prečítané + Označiť ako neprečítané + Označiť hviezdičkou + Odstrániť hviezdičku + Označiť ako… + Presunúť do… + Presunúť do koša + Odstrániť + Archivovať + Presunúť do spamu + Zobraziť správu v svetlom režime + Zobraziť správu v tmavom režime + Tlačiť + Zobraziť hlavičky + Zobraziť HTML + Nahlásiť phishing + Pripomenúť + Uložiť ako PDF + Nastaviť akciu pre budúce emaily od tohto odosielateľa + Uložiť prílohy + Viac + Odpovedať + Odpovedať všetkým + Preposlať + Označiť ako prečítané + Označiť ako neprečítané + Označiť hviezdičkou + Odstrániť hviezdičku + Označiť ako… + Presunúť do… + Kôš + Zmazať + Archivovať + Spam + Zobraziť správu v svetlom režime + Zobraziť správu v tmavom režime + Tlačiť + Zobraziť hlavičky + Zobraziť HTML + Nahlásiť phishing + Pripomenúť + Uložiť ako PDF + Nastaviť akciu pre budúce emaily od tohto odosielateľa + Uložiť prílohy + Viac + Načítanie akcií zlyhalo + Včera + Prispôsobiť panel nástrojov + Prispôsobiť panel nástrojov + Automaticky vymazať správy, ktoré boli v koši viac ako 30 dní. + Automaticky vymazať správy, ktoré boli v spame viac ako 30 dní. + Upgradujte svoj plán a automaticky mažte správy, ktoré boli v koši a spame viac ako 30 dní. + Správy, ktoré boli v koši a spame viac ako 30 dní, budú automaticky vymazané. + Povoliť + Nie, ďakujem + Zistiť viac + + Platnosť vypršala + %dd + %dh + %dm + Táto správa bude automaticky vymazaná o menej než hodinu + Táto správa bude automaticky vymazaná o menej než jeden deň + Táto správa bude automaticky vymazaná o %s + + %d minúta + %d minúty + %d minút + %d minút + + + %d hodina + %d hodiny + %d hodín + %d hodín + + + %d deň + %d dni + %d dní + %d dní + + + Prílohy + Notifikácie sťahovania príloh + Emaily + Upozornenia na prihlásenie + Notifikácie prichádzajúcich emailov + Notifikácie o novom prihlásení + Oficiálny + Pre spracovanie tejto akcie sa nenašla žiadna aplikácia + Skopírované do schránky + Zrušiť + Vymazať + Zrušiť + Fialová + Horec + Ružová + Slivka + Jahoda + Čerešňa + Mrkva + Meď + Sahara + Pôda + Bridlicovo modrá + Kobalt + Pacifik + Oceán + Útes + Borovica + Papraď + Les + Oliva + Uhorka + Vrátiť späť + Akcia vrátená + Vrátenie akcie zlyhalo + diff --git a/mail-common/presentation/src/main/res/values-sl/strings.xml b/mail-common/presentation/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..ed3940b8fc --- /dev/null +++ b/mail-common/presentation/src/main/res/values-sl/strings.xml @@ -0,0 +1,147 @@ + + + + Poskusite znova + Nazaj na prejšnji zaslon + Niste prijavljeni + Odgovori + Odgovori vsem + Posreduj + Odgovori + Odgovori vsem + Posreduj + Označi kot prebrano + Označi kot neprebrano + Zvezdica + Odznači + Označi kot … + Premakni v … + Premakni v smetnjak + Izbriši + Arhiviraj + Premakni med neželeno pošto + Ogled sporočila v svetlem načinu + Ogled sporočila v temnem načinu + Natisni + Prikaži glave + Prikaži HTML + Prijavi lažno predstavljanje + Opomni me + Shrani kot PDF + Nastavi dejanje za prihodnja sporočila tega pošiljatelja + Shrani priponke + Več + Odgovori + Odgovori vsem + Posreduj + Označi kot prebrano + Označi kot neprebrano + Označi z zvezdico + Odstrani zvezdico + Označi kot … + Premakni v … + Smetnjak + Izbriši + Arhiviraj + Neželena pošta + Ogled sporočila v svetlem načinu + Ogled sporočila v temnem načinu + Natisni + Prikaži glave + Prikaži HTML + Prijavi lažno predstavljanje + Opomni me + Shrani kot PDF + Nastavi dejanje za prihodnja sporočila tega pošiljatelja + Shrani priponke + Več + Neuspešno nalaganje dejanj + Včeraj + Prilagajanje orodne vrstice + Prilagajanje orodne vrstice + Samodejno izbriši sporočila, ki so v smetnjaku več kot 30 dni. + Samodejno izbriši sporočila, ki so med neželeno pošto več kot 30 dni. + Nadgradite paket za samodejno brisanje sporočil, ki so v smetnjaku ali neželeni pošti več kot 30 dni. + Sporočila, ki bodo v smetnjaku ali v neželeni pošti več kot 30 dni, se bodo samodejno izbrisala. + Omogoči + Ne, hvala + Več o tem + + Poteklo + %dd + %d ur + %d min + To sporočilo bo samodejno izbrisano čez manj kot eno uro + To sporočilo bo samodejno izbrisano čez manj kot en dan + To sporočilo bo samodejno izbrisano v %s + + %d minuta + %d minuti + %d minute + %d minut + + + %d ura + %d uri + %d ure + %d ur + + + %d dan + %d dneva + %d dnevi + %d dni + + + Priponke + Obvestila o prenosih priponk + Sporočila + Obvestila o prijavah + Obvestila o prejeti e-pošti + Obvestila o novih prijavah + Uradno + Najdena ni bila nobena aplikacija za to opravilo + Kopirano v odložišče + Prekliči + Izbriši + Prekliči + Vijolična + Encijan + Rožnata + Slivnato vijolična + Jagodno rdeča + Češnjevo rdeča + Korenčkasto oranžna + Bakrena + Saharsko rjava + Zemeljsko rjava + Skrilasto modra + Kobaltna + Pacifiško modra + Oceansko modra + Koralno modra + Bor + Praprotno zelena + Gozdno zelena + Olivno zelena + Kumarično zelena + Razveljavi + Dejanje razveljavljeno + Razveljavitev dejanja ni uspela + diff --git a/mail-common/presentation/src/main/res/values-sv-rSE/strings.xml b/mail-common/presentation/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..0d7858cdaa --- /dev/null +++ b/mail-common/presentation/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,141 @@ + + + + Försök igen + Tillbaka till föregående skärm + Inte inloggad + Svara + Svara alla + Vidarebefordra + Svara + Svara alla + Vidarebefordra + Markera som läst + Markera som oläst + Stjärnmärk + Ta bort stjärnmärkning + Märk som… + Flytta till… + Flytta till papperskorg + Ta bort + Arkivera + Flytta till skräppost + Visa meddelande i ljust läge + Visa meddelande i mörkt läge + Skriv ut + Visa rubriker + Visa HTML + Rapportera nätfiske + Påminn mig + Spara som PDF + Ställ in åtgärd för framtida e-postmeddelanden från denna avsändare + Spara bilagorna + Mer + Svara + Svara alla + Vidarebefordra + Markera som läst + Markera som oläst + Stjärnmärk + Ta bort stjärnmärkning + Märk som… + Flytta till… + Lägg i papperskorg + Ta bort + Arkivera + Skräppost + Visa meddelande i ljust läge + Visa meddelande i mörkt läge + Skriv ut + Visa rubriker + Visa HTML + Rapportera nätfiske + Påminn mig + Spara som PDF + Ställ in åtgärd för framtida e-postmeddelanden från denna avsändare + Spara bilagorna + Mer + Misslyckades ladda åtgärder + Igår + Anpassa verktygsfält + Anpassa verktygsfält + Ta bort meddelanden som har legat i papperskorgen i mer än 30 dagar automatiskt. + Ta bort meddelanden som har legat i skräpposten i mer än 30 dagar automatiskt. + Uppgradera för att ta bort meddelanden som har legat i papperskorg och skräppost i mer än 30 dagar automatiskt. + Meddelanden som har legat i papperskorgen och skräpposten i mer än 30 dagar kommer att tas bort automatiskt. + Aktivera + Nej, tack + Lär dig mer + + Löpt ut + %dd + %dt + %dm + Detta meddelande kommer att tas bort automatiskt om mindre än en timme + Detta meddelande kommer att tas bort automatiskt om mindre än en dag + Detta meddelande kommer tas bort automatiskt om %s + + %d minut + %d minuter + + + %d timme + %d timmar + + + %d dag + %d dagar + + + Bilagor + Aviseringar för nedladdning av bilagor + E-post + Inloggningsvarningar + Inkommande e-postaviseringar + Nya inloggningsaviseringar + Officiell + Ingen app hittades för att hantera denna åtgärd + Kopierat till urklipp + Avbryt + Ta bort + Avbryt + Lila + Enzian + Rosa + Plommon + Jordgubb + Körsbär + Morot + Koppar + Sahara + Jord + Skifferblå + Kobolt + Stilla havet + Hav + Rev + Tall + Ormbunke + Skog + Oliv + Saltgurka + Ångra + Åtgärd återställd + Återställning av åtgärd misslyckades + diff --git a/mail-common/presentation/src/main/res/values-tr/strings.xml b/mail-common/presentation/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..478ceb0f46 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-tr/strings.xml @@ -0,0 +1,141 @@ + + + + Yeniden dene + Önceki ekrana dön + Oturum açılmamış + Yanıtla + Tümünü yanıtla + İlet + Yanıtla + Tümünü yanıtla + İlet + Okunmuş olarak işaretle + Okunmamış olarak işaretle + Yıldız koy + Yıldızı kaldır + Etiketle… + Taşı… + Çöpe at + Sil + Arşivle + İstenmeyen kutusuna taşı + İletiyi açık tema ile görüntüle + İletiyi koyu tema ile görüntüle + Yazdır + Üst bilgileri görüntüle + HTML olarak görüntüle + Kimlik avı girişimi bildir + Bana hatırlat + PDF olarak kaydet + Gelecekte bu göndericinin e-postalarına yapılacak işlemi ayarlayın + Ek dosyaları kaydet + Diğer + Yanıtla + Tümünü yanıtla + İlet + Okunmuş olarak işaretle + Okunmamış olarak işaretle + Yıldız koy + Yıldızı kaldır + Etiketle… + Taşı… + Çöpe at + Sil + Arşivle + İstenmeyen e-posta + İletiyi açık tema ile görüntüle + İletiyi koyu tema ile görüntüle + Yazdır + Üst bilgileri görüntüle + HTML olarak görüntüle + Kimlik avı girişimi bildir + Bana hatırlat + PDF olarak kaydet + Gelecekte bu göndericinin e-postalarına yapılacak işlemi ayarlayın + Ek dosyaları kaydet + Diğer + İşlemler yüklenemedi + Dün + Araç çubuğunu özelleştir + Araç çubuğunu özelleştir + Çöp kutusunda 30 günden uzun süre kalan iletileri otomatik olarak silin. + İstenmeyen kutusunda 30 günden uzun süre kalan iletileri otomatik olarak silin. + Çöp veya istenmeyen kutularında 30 günden uzun süredir duran iletilerin otomatik olarak silinmesi için tarifenizi yükseltin. + Çöp ve istenmeyen kutularında 30 günden uzun süredir duran iletiler otomatik olarak silinir. + Kullanıma al + Hayır, teşekkürler + Ayrıntılı bilgi alın + + Geçerlilik süresi dolmuş + %dg + %ds + %dd + Bu ileti bir saat içinde otomatik olarak silinecek + Bu ileti bir gün içinde otomatik olarak silinecek + Bu ileti %s içinde otomatik olarak silinecek + + %d dakika + %d dakika + + + %d saat + %d saat + + + %d gün + %d gün + + + Ek dosyalar + Ek dosya indirmeleri için bildirimler + E-postalar + Oturum açma uyarıları + Gelen e-posta bildirimleri + Yeni oturumların bildirimleri + Resmi + Bu işlemi yapacak bir uygulama bulunamadı + Panoya kopyalandı + İptal + Sil + İptal + Mor + Kantaron + Pembe + Erik + Çilek + Kiraz + Havuç + Bakır + Çöl + Toprak + Arduvaz Mavisi + Kobalt + Pasifik + Okyanus + Resif + Çam + Eğrelti otu + Orman + Zeytin + Salatalık + Geri al + İşlem geri alındı + İşlem geri alınamadı + diff --git a/mail-common/presentation/src/main/res/values-uk/strings.xml b/mail-common/presentation/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..72f6fe975e --- /dev/null +++ b/mail-common/presentation/src/main/res/values-uk/strings.xml @@ -0,0 +1,147 @@ + + + + Повторити + Повернутися на попередній екран + Ви не увійшли + Відповісти + Відповісти всім + Переслати + Відповісти + Відповісти всім + Переслати + Позначити прочитаним + Позначити непрочитаним + Позначити зірочкою + Прибрати зірочку + Позначити міткою… + Перемістити… + Перемістити до смітника + Видалити + Архівувати + Перемістити до спаму + Переглянути повідомлення у світлому режимі + Переглянути повідомлення в темному режимі + Друкувати + Переглянути заголовки + Переглянути HTML + Повідомити про шахрайство + Нагадати + Зберегти як PDF + Налаштувати дію для майбутніх листів від цього відправника + Зберегти вкладення + Більше + Відповісти + Відповісти всім + Переслати + Позначити прочитаним + Позначити непрочитаним + Позначити зірочкою + Прибрати зірочку + Позначити міткою… + Перемістити… + Смітник + Видалити + Архівувати + Спам + Переглянути повідомлення у світлому режимі + Переглянути повідомлення в темному режимі + Друкувати + Переглянути заголовки + Переглянути HTML + Повідомити про шахрайство + Нагадати мені + Зберегти як PDF + Налаштувати дію для майбутніх листів від цього відправника + Зберегти вкладення + Більше + Не вдалося завантажити дії + Учора + Налаштувати панель інструментів + Налаштувати панель інструментів + Автоматично видаляти повідомлення у смітнику через 30 днів. + Автоматично видаляти повідомлення у спамі через 30 днів. + Передплатіть тарифний план, щоб повідомлення, які перебувають у смітнику або спамі понад 30 днів, видалялися автоматично. + Повідомлення, що перебувають у смітнику та спамі понад 30 днів, автоматично видалятимуться. + Увімкнути + Ні, дякую + Докладніше + + Термін дії закінчився + %d дн + %d год + %d хв + Це повідомлення автоматично видалиться менш ніж за годину + Це повідомлення автоматично видалиться менш ніж за день + Це повідомлення автоматично видалиться через %s + + %d хвилина + %d хвилини + %d хвилин + %d хвилин + + + %d година + %d години + %d годин + %d годин + + + %d день + %d дні + %d днів + %d днів + + + Вкладення + Сповіщення про завантаження вкладень + Листи + Сповіщення про вхід + Сповіщення про вхідну пошту + Сповіщення про новий вхід + Офіційний + Не знайдено програми для обробки цієї дії + Скопійовано до буфера обміну + Скасувати + Видалити + Скасувати + Бузковий + Енізан + Рожевий + Сливовий + Полуничний + Вишневий + Морквяний + Мідний + Сахара + Земляний + Сіро-блакитний + Кобальтовий + Тихоокеанський + Океанічний + Рифовий + Сосновий + Папороть + Лісовий + Оливковий + Сіро-зелений + Скасувати + Дію скасовано + Не вдалося скасувати дію + diff --git a/mail-common/presentation/src/main/res/values-zh-rCN/strings.xml b/mail-common/presentation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..a1c7e4b6a2 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,138 @@ + + + + 重试 + 返回前一界面 + 未登录 + 回复 + 全部回复 + 转发 + 回复 + 回复所有人 + 转发 + 标为已读 + 标为未读 + 星标 + 取消星标 + 标记为… + 移至… + 移至回收站 + 刪除 + 归档 + 标为垃圾邮件 + 在浅色模式下阅读邮件 + 在深色模式下阅读邮件 + 打印 + 查看邮件头信息 + 以 HTML 方式查看 + 举报欺诈邮件 + 提醒我 + 另存为PDF + 对此发件人今后的邮件设置收信规则 + 保存附件 + 更多 + 回复 + 回复所有人 + 转发 + 标为已读 + 标为未读 + 星标 + 取消星标 + 标记为… + 移至… + 移至回收站 + 删除 + 存档 + 标为垃圾邮件 + 在浅色模式下阅读邮件 + 在深色模式下阅读邮件 + 打印 + 查看邮件头信息 + 查看 HTML 代码 + 举报欺诈邮件 + 稍后提醒我 + 保存为 PDF 文件 + 对此发件人今后的邮件设置收信规则 + 保存附件 + 更多 + 加载操作失败 + 昨天 + 自定义工具栏 + 自定义工具栏 + 自动删除在回收站中超过 30 天的邮件。 + 自动删除垃圾邮件中超过 30 天的邮件。 + 升级账户即可自动删除归入垃圾邮件或回收站超过 30 天的邮件。 + 已归入垃圾邮件或回收站超过 30 天的邮件将被自动删除。 + 启用 + 不,谢谢 + 了解详情 + + 已过期 + %d 天 + %d 小时 + %d 分钟 + 此消息将在一小时内自动删除 + 此消息将在一天内自动删除 + 此消息将于 %s 内自动删除 + + %d 分钟 + + + %d 小时 + + + %d 天 + + + 附件 + 附件下载通知 + 电子邮件 + 登录提醒 + 新邮件提醒 + 新设备登录通知 + 官方 + 未找到能完成此操作的应用 + 已复制到剪贴板 + 取消 + 刪除 + 取消 + 紫色 + 靛蓝色 + 粉红色 + 绛紫色 + 草莓红 + 樱桃红 + 胡萝卜橙 + 紫铜色 + 黄土色 + 泥土色 + 石板蓝色 + 钴蓝色 + 太平洋蓝 + 海蓝色 + 礁石色 + 松林绿 + 厥草绿 + 森林绿 + 橄榄绿 + 腌黄瓜色 + 撤销 + 操作已撤销 + 操作撤销失败 + diff --git a/mail-common/presentation/src/main/res/values-zh-rTW/strings.xml b/mail-common/presentation/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..f743754d98 --- /dev/null +++ b/mail-common/presentation/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,138 @@ + + + + 重試 + 回到前一個畫面 + 尚未登入 + 回覆 + 回覆全部收件者 + 轉發 + 回覆 + 全部回覆 + 轉寄 + 標示為已讀取 + 標示為未讀取 + 標注星號 + 取消星號 + 標簽注記為… + 移至… + 移至垃圾桶 + 刪除 + 封存 + 移動至垃圾郵件 + 以淺色模式檢視郵件訊息 + 以深色模式檢視郵件訊息 + 列印 + 檢視標頭 + 以 HTML 瀏覽 + 回報網路釣魚 + 提醒我 + 儲存為 PDF + 設定動作套用於日後接獲寄件者傳送的電郵 + 儲存附件 + 更多 + 回覆 + 回覆全部收件者 + 轉寄 + 標記為已讀 + 標記為未讀 + 加上星號 + 移除星號 + 標註… + 移至… + 丟到垃圾桶 + 刪除 + 封存 + 垃圾郵件 + 以淺色模式檢視郵件訊息 + 以深色模式檢視郵件訊息 + 列印 + 檢視表頭 + 以 HTML 瀏覽 + 提報釣魚郵件 + 提醒我 + 儲存為 PDF + 設定動作套用於日後接獲寄件者傳送的電郵 + 儲存附件 + 更多詳情 + 無法載入動作 + 昨天 + 自訂工具列 + 自訂工具列 + 於垃圾桶中超過 30 天的訊息將自動被刪除。 + 於垃圾郵件中超過 30 天的訊息將自動被刪除。 + 升級以自動刪除在垃圾桶和垃圾郵件中超過 30 天的訊息。 + 在垃圾桶與垃圾郵件中超過 30 天的訊息將會被自動刪除。 + 啟用 + 不用了,謝謝 + 了解更多 + + 已過期 + %d天 + %d小時 + %d分鐘 + 此郵件將於一小時內自動刪除 + 此郵件將於一天內自動刪除 + 此郵件將會自動於%s刪除 + + %d分鐘 + + + %d小時 + + + %d天 + + + 附件 + 附件下載通知 + 電子郵件 + 登入警告 + 來信通知 + 新的登入通知 + 官方郵件 + 未找到可處理此操作的程式 + 已複製到剪貼簿 + 取消 + 刪除 + 取消 + 紫色 + 龍膽紫 + 粉紅 + 梅紅色 + 草莓紅 + 櫻桃紅 + 胡蘿蔔橘 + 銅色 + 黃土色 + 泥土色 + 板岩藍 + 鈷藍 + 太平洋藍 + 海洋藍 + 礁石色 + 墨松青 + 淺蕨綠 + 森林綠 + 橄欖綠 + 醃黃瓜色 + 復原 + 動作復原 + 動作復原失敗 + diff --git a/mail-common/presentation/src/main/res/values/strings.xml b/mail-common/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..d607582803 --- /dev/null +++ b/mail-common/presentation/src/main/res/values/strings.xml @@ -0,0 +1,151 @@ + + + + Retry + Back to previous screen + Not signed in + Reply + Reply all + Forward + Reply + Reply all + Forward + Mark read + Mark unread + Star + Unstar + Label as… + Move to… + Move to trash + Delete + Archive + Move to spam + View message in light mode + View message in dark mode + Print + View headers + View HTML + Report phishing + Remind me + Save as PDF + Set action for future emails from this sender + Save attachments + More + Reply + Reply all + Forward + Mark read + Mark unread + Star + Unstar + Label as… + Move to… + Trash + Delete + Archive + Spam + View message in light mode + View message in dark mode + Print + View headers + View HTML + Report phishing + Remind me + Save as PDF + Set action for future emails from this sender + Save attachments + More + Failed loading actions + Yesterday + Customize toolbar + Customize toolbar + + Automatically delete messages that have been in trash for more than 30 days. + Automatically delete messages that have been in spam for more than 30 days. + Upgrade to automatically delete messages that have been in trash or spam for more than 30 days. + Messages that have been in trash and spam more than 30 days will be automatically deleted. + Enable + No, thanks + Learn more + + + Expired + %dd + %dh + %dm + This message will be automatically deleted in less than an hour + This message will be automatically deleted in less than a day + This message will be automatically deleted in %s + + + %d minute + %d minutes + + + %d hour + %d hours + + + %d day + %d days + + + + Attachments + Notifications for attachment downloads + Emails + Login Alerts + Incoming email notifications + New logins notifications + + Official + + No app found to handle this action + + Copied to clipboard + + Cancel + Delete + + Cancel + + Purple + Enzian + Pink + Plum + Strawberry + Cerise + Carrot + Copper + Sahara + Soil + Slate blue + Cobalt + Pacific + Ocean + Reef + Pine + Fern + Forest + Olive + Pickle + Undo + Action reverted + Action revert failed + diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/EffectTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/EffectTest.kt new file mode 100644 index 0000000000..ff5c69acf6 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/EffectTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation + +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertNull + +internal class EffectTest { + + @Test + fun `event is returned correctly on consume`() { + // given + val event = "hello" + val effect = Effect.of(event) + + // when - then + assertEquals(event, effect.consume()) + } + + @Test + fun `event is cleared on consume`() { + // given + val effect = Effect.of("hello") + + // when + effect.consume() + + // then + assertNull(effect.consume()) + } + + @Test + fun `state flow should not emit the same effect instance after it's consumed`() = runTest { + // given + val effect = Effect.of(42) + val stateFlow = MutableStateFlow(effect) + + // when/then + stateFlow.test { + val initialState = awaitItem() + assertEquals(effect, initialState) + + effect.consume() + stateFlow.value = effect + expectNoEvents() + } + } + + @Test + fun `should compare the event value and not the instance when doing the equals check`() { + // given + val firstEffect = Effect.of(42) + val secondEffect = Effect.of(42) + + // when/then + assertEquals(firstEffect, secondEffect) + assertNotSame(firstEffect, secondEffect) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapperTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapperTest.kt new file mode 100644 index 0000000000..6e7e45cc4f --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ActionUiModelMapperTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import ch.protonmail.android.mailcommon.domain.model.Action +import ch.protonmail.android.mailcommon.presentation.model.ActionUiModel +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import me.proton.core.presentation.R +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals +import ch.protonmail.android.mailcommon.presentation.R as commonRes + +@RunWith(Parameterized::class) +class ActionUiModelMapperTest( + @Suppress("UNUSED_PARAMETER") private val testName: String, + private val testInput: TestParams.TestInput +) { + + private val mapper = ActionUiModelMapper() + + @Test + fun `map action to action ui model`() { + // Given + val action = testInput.action + // When + val actual = mapper.toUiModel(action) + // Then + assertEquals(testInput.expected, actual) + } + + companion object { + + private val actions = listOf( + TestParams( + "Map MarkRead action to UiModel", + TestParams.TestInput( + action = Action.MarkRead, + expected = ActionUiModel( + Action.MarkRead, + R.drawable.ic_proton_envelope, + TextUiModel(commonRes.string.action_mark_read_description), + TextUiModel(commonRes.string.action_mark_read_content_description) + ) + ) + ), + TestParams( + "Map MarkUnread action to UiModel", + TestParams.TestInput( + action = Action.MarkUnread, + expected = ActionUiModel( + Action.MarkUnread, + R.drawable.ic_proton_envelope_dot, + TextUiModel(commonRes.string.action_mark_unread_description), + TextUiModel(commonRes.string.action_mark_unread_content_description) + ) + ) + ), + TestParams( + "Map Star action to UiModel", + TestParams.TestInput( + action = Action.Star, + expected = ActionUiModel( + Action.Star, + R.drawable.ic_proton_star, + TextUiModel(commonRes.string.action_star_description), + TextUiModel(commonRes.string.action_star_content_description) + ) + ) + ), + TestParams( + "Map Unstar action to UiModel", + TestParams.TestInput( + action = Action.Unstar, + expected = ActionUiModel( + Action.Unstar, + R.drawable.ic_proton_star_slash, + TextUiModel(commonRes.string.action_unstar_description), + TextUiModel(commonRes.string.action_unstar_content_description) + ) + ) + ), + TestParams( + "Map Label action to UiModel", + TestParams.TestInput( + action = Action.Label, + expected = ActionUiModel( + Action.Label, + R.drawable.ic_proton_tag, + TextUiModel(commonRes.string.action_label_description), + TextUiModel(commonRes.string.action_label_content_description) + ) + ) + ), + TestParams( + "Map Move action to UiModel", + TestParams.TestInput( + action = Action.Move, + expected = ActionUiModel( + Action.Move, + R.drawable.ic_proton_folder_arrow_in, + TextUiModel(commonRes.string.action_move_description), + TextUiModel(commonRes.string.action_move_content_description) + ) + ) + ), + TestParams( + "Map Trash action to UiModel", + TestParams.TestInput( + action = Action.Trash, + expected = ActionUiModel( + Action.Trash, + R.drawable.ic_proton_trash, + TextUiModel(commonRes.string.action_trash_description), + TextUiModel(commonRes.string.action_trash_content_description) + ) + ) + ), + TestParams( + "Map Archive action to UiModel", + TestParams.TestInput( + action = Action.Archive, + expected = ActionUiModel( + Action.Archive, + R.drawable.ic_proton_archive_box, + TextUiModel(commonRes.string.action_archive_description), + TextUiModel(commonRes.string.action_archive_content_description) + ) + ) + ), + TestParams( + "Map Spam action to UiModel", + TestParams.TestInput( + action = Action.Spam, + expected = ActionUiModel( + Action.Spam, + R.drawable.ic_proton_fire, + TextUiModel(commonRes.string.action_spam_description), + TextUiModel(commonRes.string.action_spam_content_description) + ) + ) + ), + TestParams( + "Map ViewInLightMode action to UiModel", + TestParams.TestInput( + action = Action.ViewInLightMode, + expected = ActionUiModel( + Action.ViewInLightMode, + R.drawable.ic_proton_sun, + TextUiModel(commonRes.string.action_view_in_light_mode_description), + TextUiModel(commonRes.string.action_view_in_light_mode_content_description) + ) + ) + ), + TestParams( + "Map ViewInDarkMode action to UiModel", + TestParams.TestInput( + action = Action.ViewInDarkMode, + expected = ActionUiModel( + Action.ViewInDarkMode, + R.drawable.ic_proton_moon, + TextUiModel(commonRes.string.action_view_in_dark_mode_description), + TextUiModel(commonRes.string.action_view_in_dark_mode_content_description) + ) + ) + ), + TestParams( + "Map Print action to UiModel", + TestParams.TestInput( + action = Action.Print, + expected = ActionUiModel( + Action.Print, + R.drawable.ic_proton_printer, + TextUiModel(commonRes.string.action_print_description), + TextUiModel(commonRes.string.action_print_content_description) + ) + ) + ), + TestParams( + "Map ViewHeaders action to UiModel", + TestParams.TestInput( + action = Action.ViewHeaders, + expected = ActionUiModel( + Action.ViewHeaders, + R.drawable.ic_proton_file_lines, + TextUiModel(commonRes.string.action_view_headers_description), + TextUiModel(commonRes.string.action_view_headers_content_description) + ) + ) + ), + TestParams( + "Map ViewHtml action to UiModel", + TestParams.TestInput( + action = Action.ViewHtml, + expected = ActionUiModel( + Action.ViewHtml, + R.drawable.ic_proton_code, + TextUiModel(commonRes.string.action_view_html_description), + TextUiModel(commonRes.string.action_view_html_content_description) + ) + ) + ), + TestParams( + "Map ReportPhishing action to UiModel", + TestParams.TestInput( + action = Action.ReportPhishing, + expected = ActionUiModel( + Action.ReportPhishing, + R.drawable.ic_proton_hook, + TextUiModel(commonRes.string.action_report_phishing_description), + TextUiModel(commonRes.string.action_report_phishing_content_description) + ) + ) + ), + TestParams( + "Map Remind action to UiModel", + TestParams.TestInput( + action = Action.Remind, + expected = ActionUiModel( + Action.Remind, + R.drawable.ic_proton_clock, + TextUiModel(commonRes.string.action_remind_description), + TextUiModel(commonRes.string.action_remind_content_description) + ) + ) + ), + TestParams( + "Map SavePdf action to UiModel", + TestParams.TestInput( + action = Action.SavePdf, + expected = ActionUiModel( + Action.SavePdf, + R.drawable.ic_proton_arrow_down_line, + TextUiModel(commonRes.string.action_save_pdf_description), + TextUiModel(commonRes.string.action_save_pdf_content_description) + ) + ) + ), + TestParams( + "Map SenderEmails action to UiModel", + TestParams.TestInput( + action = Action.SenderEmails, + expected = ActionUiModel( + Action.SenderEmails, + R.drawable.ic_proton_envelope, + TextUiModel(commonRes.string.action_sender_emails_description), + TextUiModel(commonRes.string.action_sender_emails_content_description) + ) + ) + ), + TestParams( + "Map SaveAttachments action to UiModel", + TestParams.TestInput( + action = Action.SaveAttachments, + expected = ActionUiModel( + Action.SaveAttachments, + R.drawable.ic_proton_arrow_down_to_square, + TextUiModel(commonRes.string.action_save_attachments_description), + TextUiModel(commonRes.string.action_save_attachments_content_description) + ) + ) + ) + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = actions + .map { arrayOf(it.testName, it.testInput) } + } + + data class TestParams( + val testName: String, + val testInput: TestInput + ) { + + data class TestInput( + val action: Action, + val expected: ActionUiModel + ) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapperTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapperTest.kt new file mode 100644 index 0000000000..6cda806bfa --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ColorMapperTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import androidx.compose.ui.graphics.Color +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class ColorMapperTest( + private val testName: String, + private val input: String, + private val expected: Either +) { + + @Test + fun test() { + println("Running $testName, Input: $input, Expected: $expected") + assertEquals(expected, ColorMapper().toColor(input)) + } + + data class Params( + val testName: String, + val input: String, + val expected: Either + ) + + companion object { + + @JvmStatic + @Parameters(name = "{0}") + fun data() = listOf( + + Params( + testName = "success from #aarrggbb string", + input = "#80000000", + expected = Color.Black.copy(alpha = 0.5f).right() + ), + + Params( + testName = "success from aarrggbb string", + input = "80000000", + expected = Color.Black.copy(alpha = 0.5f).right() + ), + + Params( + testName = "success from #rrggbb string", + input = "#000000", + expected = Color.Black.right() + ), + + Params( + testName = "success from rrggbb string", + input = "000000", + expected = Color.Black.right() + ), + + Params( + testName = "success from #rgb string", + input = "#000", + expected = Color.Black.right() + ), + + Params( + testName = "success from rgb string", + input = "000", + expected = Color.Black.right() + ), + + Params( + testName = "success for red color", + input = "ff0000", + expected = Color.Red.right() + ), + + Params( + testName = "success for green color", + input = "00ff00", + expected = Color.Green.right() + ), + + Params( + testName = "success for blue color", + input = "0000ff", + expected = Color.Blue.right() + ), + + Params( + testName = "from invalid string", + input = "invalid", + expected = "invalid".left() + ) + + ).map { arrayOf(it.testName, it.input, it.expected) } + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapperTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapperTest.kt new file mode 100644 index 0000000000..ff1aee9b0f --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/ExpirationTimeMapperTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import ch.protonmail.android.mailcommon.domain.sample.DurationEpochTimeSample +import ch.protonmail.android.mailcommon.domain.usecase.GetCurrentEpochTimeDuration +import ch.protonmail.android.mailcommon.presentation.R.string +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +internal class ExpirationTimeMapperTest { + + private val now = DurationEpochTimeSample.Y2022.Dec.D25.Midnight + private val getCurrentEpochTimeDuration: GetCurrentEpochTimeDuration = mockk { + every { this@mockk() } returns now + } + private val mapper = ExpirationTimeMapper(getCurrentEpochTimeDuration) + + @Test + fun `when expiration is in the past, then expired is returned`() { + // given + val expiration = now - 10.minutes + val expected = TextUiModel(string.expiration_expired) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } + + @Test + fun `when expiration in 10 minutes, then 10 minutes is returned`() { + // given + val expiration = now + 10.minutes + val expected = TextUiModel(value = string.expiration_minutes_arg, 10) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } + + @Test + fun `when expiration in 1 hour, then 1 hour is returned`() { + // given + val expiration = now + 1.hours + val expected = TextUiModel(value = string.expiration_hours_arg, 1) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } + + @Test + fun `when expiration in 1 hour 10 minutes, then 1 hour is returned`() { + // given + val expiration = now + 1.hours + 10.minutes + val expected = TextUiModel(value = string.expiration_hours_arg, 1) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } + + @Test + fun `when expiration is 1 day, then 1 day is returned`() { + // given + val expiration = now + 1.days + val expected = TextUiModel(value = string.expiration_days_arg, 1) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } + + @Test + fun `when expiration is 1 year, then 365 days is returned`() { + // given + val expiration = now + 365.days + val expected = TextUiModel(value = string.expiration_days_arg, 365) + + // when + val actual = mapper.toUiModel(expiration) + + // then + assertEquals(expected, actual) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapperTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapperTest.kt new file mode 100644 index 0000000000..39d7a97981 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/mapper/UnreadCountValueMapperTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.mapper + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class UnreadCountValueMapperTest { + + private val mapper = UnreadCountValueMapper + + @Test + fun `should return the value as is if it is less than 9999`() { + // Given + val value = 19 + val expectedString = value.toString() + + // When + val actual = mapper.toCappedValue(value) + + // Then + assertEquals(expectedString, actual) + } + + @Test + fun `should return the value as is if it equals to 9999`() { + // Given + val value = 9999 + val expectedString = value.toString() + + // When + val actual = mapper.toCappedValue(value) + + // Then + assertEquals(expectedString, actual) + } + + @Test + fun `should cap the value if it is greater than 9999`() { + // Given + val value = 10_000 + val expectedString = "9999+" + + // When + val actual = mapper.toCappedValue(value) + + // Then + assertEquals(expectedString, actual) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModelTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModelTest.kt new file mode 100644 index 0000000000..d94a85cdd4 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/model/TextUiModelTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +internal class TextUiModelTest { + + @Test + fun `equals on text`() { + assertEquals(TextUiModel("a"), TextUiModel("a")) + assertNotEquals(TextUiModel("a"), TextUiModel("b")) + assertNotEquals(TextUiModel("a"), TextUiModel("A")) + } + + @Test + fun `equals on text res`() { + assertEquals(TextUiModel(1), TextUiModel(1)) + assertNotEquals(TextUiModel(1), TextUiModel(2)) + } + + @Test + fun `equals on text res with args`() { + data class Test(val int: Int) + assertEquals(TextUiModel(1, "a"), TextUiModel(1, "a")) + assertEquals(TextUiModel(1, 1), TextUiModel(1, 1)) + assertEquals(TextUiModel(1, Test(1)), TextUiModel(1, Test(1))) + assertNotEquals(TextUiModel(1, "a"), TextUiModel(1, "b")) + assertNotEquals(TextUiModel(1, "a"), TextUiModel(2, "a")) + assertNotEquals(TextUiModel(1, 1), TextUiModel(1, 2)) + assertNotEquals(TextUiModel(1, 1), TextUiModel(1, "a")) + assertNotEquals(TextUiModel(1, Test(1)), TextUiModel(1, Test(2))) + } + + @Test + fun `hash code on text`() { + assertEquals(TextUiModel("a").hashCode(), TextUiModel("a").hashCode()) + assertNotEquals(TextUiModel("a").hashCode(), TextUiModel("b").hashCode()) + assertNotEquals(TextUiModel("a").hashCode(), TextUiModel("A").hashCode()) + } + + @Test + fun `hash code on text res`() { + assertEquals(TextUiModel(1).hashCode(), TextUiModel(1).hashCode()) + assertNotEquals(TextUiModel(1).hashCode(), TextUiModel(2).hashCode()) + } + + @Test + fun `hash code on text res with args`() { + data class Test(val int: Int) + assertEquals(TextUiModel(1, "a").hashCode(), TextUiModel(1, "a").hashCode()) + assertEquals(TextUiModel(1, 1).hashCode(), TextUiModel(1, 1).hashCode()) + assertEquals(TextUiModel(1, Test(1)).hashCode(), TextUiModel(1, Test(1)).hashCode()) + assertNotEquals(TextUiModel(1, "a").hashCode(), TextUiModel(1, "b").hashCode()) + assertNotEquals(TextUiModel(1, "a").hashCode(), TextUiModel(2, "a").hashCode()) + assertNotEquals(TextUiModel(1, 1).hashCode(), TextUiModel(1, 2).hashCode()) + assertNotEquals(TextUiModel(1, 1).hashCode(), TextUiModel(1, "a").hashCode()) + assertNotEquals(TextUiModel(1, Test(1)).hashCode(), TextUiModel(1, Test(2)).hashCode()) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducerTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducerTest.kt new file mode 100644 index 0000000000..5fb7cb6582 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/reducer/BottomBarReducerTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.reducer + +import arrow.core.nonEmptyListOf +import ch.protonmail.android.mailcommon.presentation.model.BottomBarEvent +import ch.protonmail.android.mailcommon.presentation.model.BottomBarState +import ch.protonmail.android.testdata.action.ActionUiModelTestData +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class BottomBarReducerTest( + private val testName: String, + private val testInput: TestInput +) { + + private val reducer = BottomBarReducer() + + @Test + fun `should produce the expected new state`() = with(testInput) { + val actualState = reducer.newStateFrom(currentState, operation) + + assertEquals(expectedState, actualState, testName) + } + + companion object { + + private val actions = nonEmptyListOf(ActionUiModelTestData.markUnread).toImmutableList() + private val updatedActions = listOf(ActionUiModelTestData.archive).toImmutableList() + + private val transitionsFromLoadingState = listOf( + TestInput( + currentState = BottomBarState.Loading, + operation = BottomBarEvent.ActionsData(actions), + expectedState = BottomBarState.Data.Hidden(actions) + ), + TestInput( + currentState = BottomBarState.Loading, + operation = BottomBarEvent.ShowAndUpdateActionsData(actions), + expectedState = BottomBarState.Data.Shown(actions) + ), + TestInput( + currentState = BottomBarState.Loading, + operation = BottomBarEvent.ErrorLoadingActions, + expectedState = BottomBarState.Error.FailedLoadingActions + ) + ) + + private val transitionsFromDataState = listOf( + TestInput( + currentState = BottomBarState.Data.Hidden(actions), + operation = BottomBarEvent.ActionsData(updatedActions), + expectedState = BottomBarState.Data.Hidden(updatedActions) + ), + TestInput( + currentState = BottomBarState.Data.Shown(actions), + operation = BottomBarEvent.ActionsData(updatedActions), + expectedState = BottomBarState.Data.Shown(updatedActions) + ), + TestInput( + currentState = BottomBarState.Data.Hidden(actions), + operation = BottomBarEvent.ErrorLoadingActions, + expectedState = BottomBarState.Data.Hidden(actions) + ), + TestInput( + currentState = BottomBarState.Data.Shown(actions), + operation = BottomBarEvent.ErrorLoadingActions, + expectedState = BottomBarState.Data.Shown(actions) + ), + TestInput( + currentState = BottomBarState.Data.Hidden(actions), + operation = BottomBarEvent.ShowBottomSheet, + expectedState = BottomBarState.Data.Shown(actions) + ), + TestInput( + currentState = BottomBarState.Data.Shown(actions), + operation = BottomBarEvent.ShowBottomSheet, + expectedState = BottomBarState.Data.Shown(actions) + ), + TestInput( + currentState = BottomBarState.Data.Shown(actions), + operation = BottomBarEvent.HideBottomSheet, + expectedState = BottomBarState.Data.Hidden(actions) + ), + TestInput( + currentState = BottomBarState.Data.Hidden(actions), + operation = BottomBarEvent.HideBottomSheet, + expectedState = BottomBarState.Data.Hidden(actions) + ) + ) + + private val transitionsFromErrorState = listOf( + TestInput( + currentState = BottomBarState.Error.FailedLoadingActions, + operation = BottomBarEvent.ActionsData(actions), + expectedState = BottomBarState.Data.Hidden(actions) + ), + TestInput( + currentState = BottomBarState.Error.FailedLoadingActions, + operation = BottomBarEvent.ShowAndUpdateActionsData(actions), + expectedState = BottomBarState.Data.Shown(actions) + ), + TestInput( + currentState = BottomBarState.Error.FailedLoadingActions, + operation = BottomBarEvent.ErrorLoadingActions, + expectedState = BottomBarState.Error.FailedLoadingActions + ), + TestInput( + currentState = BottomBarState.Error.FailedLoadingActions, + operation = BottomBarEvent.ShowBottomSheet, + expectedState = BottomBarState.Error.FailedLoadingActions + ), + TestInput( + currentState = BottomBarState.Error.FailedLoadingActions, + operation = BottomBarEvent.HideBottomSheet, + expectedState = BottomBarState.Error.FailedLoadingActions + ) + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = (transitionsFromLoadingState + transitionsFromDataState + transitionsFromErrorState) + .map { testInput -> + val testName = """ + Current state: ${testInput.currentState} + Operation: ${testInput.operation} + Next state: ${testInput.expectedState} + + """.trimIndent() + arrayOf(testName, testInput) + } + } + + data class TestInput( + val currentState: BottomBarState, + val operation: BottomBarEvent, + val expectedState: BottomBarState + ) + +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTimeTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTimeTest.kt new file mode 100644 index 0000000000..9e771243f5 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatExtendedTimeTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import java.util.Locale +import java.util.TimeZone +import ch.protonmail.android.mailcommon.domain.usecase.GetAppLocale +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class FormatExtendedTimeTest { + + private val getAppLocale: GetAppLocale = mockk() + + private val formatExtendedTime = FormatExtendedTime(getAppLocale) + + @Before + fun setUp() { + mockkStatic(TimeZone::class) + every { TimeZone.getDefault() } returns TimeZone.getTimeZone("Europe/Zurich") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `format the date and time according to the app locale`() { + // Given + val expectedResult = TextUiModel.Text("08/11/2022, 17:16") + every { getAppLocale() } returns Locale.UK + // When + val result = formatExtendedTime(1_667_924_198.seconds) + // Then + assertEquals(expectedResult, result) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTimeTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTimeTest.kt new file mode 100644 index 0000000000..fbaab91538 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/FormatShortTimeTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import ch.protonmail.android.mailcommon.domain.usecase.GetAppLocale +import ch.protonmail.android.mailcommon.domain.usecase.GetLocalisedCalendar +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailmailbox.presentation.R +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class FormatShortTimeTest { + + private val getLocalisedCalendar = mockk() + private val getAppLocale = mockk() + + private val formatter = FormatShortTime( + getLocalisedCalendar, + getAppLocale + ) + + @Before + fun setUp() { + mockkStatic(TimeZone::class) + every { TimeZone.getDefault() } returns TimeZone.getTimeZone("Europe/Zurich") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `when the message is from the current day and locale is Italian show time of the message in 24 hours format`() { + // Given + givenCurrentTimeAndLocale(1_658_853_752.seconds, Locale.ITALIAN) // Tue Jul 26 18:42:35 CEST 2022 + val itemTime = 1_658_853_643L // Tue Jul 26 18:40:44 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("18:40"), actual) + } + + @Test + fun `when the message is from the current day and locale is English show time of the message in 12 hours format`() { + // Given + givenCurrentTimeAndLocale(1_658_853_752.seconds, Locale.ENGLISH) // Tue Jul 26 18:42:35 CEST 2022 + val itemTime = 1_658_853_643L // Tue Jul 26 18:40:44 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("6:40 PM"), actual) + } + + @Test + fun `when the message is from yesterday show localized 'yesterday' string`() { + // Given + givenCurrentTimeAndLocale(1_658_853_752.seconds, Locale.UK) // Tue Jul 26 18:42:35 CEST 2022 + val itemTime = 1_658_772_437 // Mon Jul 25 20:07:17 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.TextRes(R.string.yesterday), actual) + } + + @Test + fun `when the message is from the current week and older than yesterday show week day`() { + // Given + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.UK) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_658_772_437 // Mon Jul 25 20:07:17 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("Monday"), actual) + } + + @Test + fun `when the message is of a previous year and same week-of-year as current week show full date`() { + // Given + givenCurrentTimeAndLocale(1_709_557_304.seconds, Locale.UK) // Mon Mar 04 14:01:44 CET 2024 (week 10) + val itemTime = 1_678_107_704 // Mon Mar 06 14:01:44 CET 2023 (week 10) + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("6 Mar 2023"), actual) + } + + @Test + fun `when the year changed and message is from the current week and older than yesterday show week day`() { + // Given + givenCurrentTimeAndLocale(1_640_995_200.seconds, Locale.UK) // Sat Jan 01 2022 01:00:00 CEST + val itemTime = 1_640_760_408 // Wed Dec 29 2021 07:46:48 CEST + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("Wednesday"), actual) + } + + @Test + fun `when the message is from the current year and older than current week show day and month`() { + // Given + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.FRENCH) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_647_852_004 // Mon Mar 21 09:40:04 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("21 mars 2022"), actual) + } + + @Test + fun `when showing day and month ensure they are formatted based on the current locale`() { + // Given + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.US) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_647_852_004 // Mon Mar 21 09:40:04 CEST 2022 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("Mar 21, 2022"), actual) + } + + @Test + fun `when the message is from before the current year show the day month and year`() { + // Given + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.UK) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_634_119_200 // Wed Oct 13 12:00:00 CEST 2021 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("13 Oct 2021"), actual) + } + + @Test + fun `when the message is from Dec 31st and today is Jan 1st show yesterday`() { + // Given + givenCurrentTimeAndLocale(1_640_995_200.seconds, Locale.UK) // Sat Jan 01 2022 01:00:00 CEST + val itemTime = 1_640_989_800 // Fri Dec 31 2021 23:30:00 CEST + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.TextRes(R.string.yesterday), actual) + } + + @Test + fun `when showing day month and year ensure they are formatted based on current locale`() { + // Given + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.US) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_631_518_804 // Mon Sep 13 09:40:04 CEST 2021 + // When + val actual = formatter.invoke(itemTime.seconds) + // Then + assertIs(actual, actual.toString()) + assertEquals(TextUiModel.Text("Sep 13, 2021"), actual) + } + + @Test + fun `all instances of calendar are created considering the current locale`() { + // Given + mockkStatic(Calendar::class) + givenCurrentTimeAndLocale(1_658_994_137.seconds, Locale.TAIWAN) // Thu Jul 28 09:42:17 CEST 2022 + val itemTime = 1_631_518_804 // Mon Sep 13 09:40:04 CEST 2022 + // When + formatter.invoke(itemTime.seconds) + // Then + val slot = mutableListOf() + verify { Calendar.getInstance(capture(slot)) } + assertTrue(slot.isNotEmpty()) + assertTrue(slot.all { it == Locale.TAIWAN }) + } + + private fun givenCurrentTimeAndLocale(currentTime: Duration, locale: Locale) { + val calendar = Calendar.getInstance(locale) + calendar.time = Date(currentTime.inWholeMilliseconds) + + every { getLocalisedCalendar() } returns calendar + every { getAppLocale() } returns locale + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialTest.kt new file mode 100644 index 0000000000..8da156bcb9 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class GetInitialTest { + + private val getInitial = GetInitial() + + @Test + fun `returns emoji if the first letter of the given string is an emoji`() { + // Given + val input = "\uD83D\uDC7D Test" + val expectedResult = "\uD83D\uDC7D" + // When + val result = getInitial(input) + // Then + assertEquals(expectedResult, result) + } + + @Test + fun `returns first char of the given string to uppercase`() { + // Given + val input = "any normal string" + val expectedResult = "A" + // When + val result = getInitial(input) + // Then + assertEquals(expectedResult, result) + } + + @Test + fun `returns null when given string is empty`() { + // Given + val input = "" + // When + val result = getInitial(input) + // Then + assertNull(result) + } + + @Test + fun `returns null when given string is blank`() { + // Given + val input = " " + // When + val result = getInitial(input) + // Then + assertNull(result) + } + + @Test + fun `returns first char only when input is high surrogate not followed by another char`() { + // Given + val input = "\uD83D" + val expected = "\uD83D" + // When + val result = getInitial(input) + // Then + assertEquals(expected, result) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialsTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialsTest.kt new file mode 100644 index 0000000000..602c376265 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/usecase/GetInitialsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcommon.presentation.usecase + +import org.junit.Test +import kotlin.test.assertEquals + +class GetInitialsTest { + + private val getInitials = GetInitials() + + @Test + fun `get initials for empty string`() { + // Given + val name = "" + val expectedInitials = "" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } + + @Test + fun `get initials for one word lowercase name`() { + // Given + val name = "aaa" + val expectedInitials = "A" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } + + @Test + fun `get initials for multiple word lowercase name`() { + // Given + val name = "aaa bbb ccc ddd" + val expectedInitials = "AD" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } + + @Test + fun `get initials for multiple word name starting with numbers`() { + // Given + val name = "0 1 2 3" + val expectedInitials = "03" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } + + @Test + fun `get initials for multiple word name starting with non-letter, non-digit characters`() { + // Given + val name = "#abc !def %ghi" + val expectedInitials = "#%" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } + + @Test + fun `get initials for name with emojis only`() { + // Given + val name = "\uD83D\uDE0A\uD83D\uDE0D" + val expectedInitials = "\uD83D\uDE0A" + + // When + val actual = getInitials(name) + + // Then + assertEquals(actual, expectedInitials) + } +} diff --git a/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModelTest.kt b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModelTest.kt new file mode 100644 index 0000000000..a50de2bb48 --- /dev/null +++ b/mail-common/presentation/src/test/kotlin/ch/protonmail/android/mailcommon/presentation/viewmodel/UndoOperationViewModelTest.kt @@ -0,0 +1,49 @@ +package ch.protonmail.android.mailcommon.presentation.viewmodel + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.UndoLastOperation +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class UndoOperationViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val undoLastOperation = mockk() + + private val viewModel = UndoOperationViewModel(undoLastOperation) + + @Test + fun `emits success when undo operation succeeds`() = runTest { + // Given + coEvery { undoLastOperation() } returns Unit.right() + + // When + viewModel.submitUndo() + + // Then + assertEquals(Effect.of(Unit), viewModel.state.value.undoSucceeded) + assertEquals(Effect.empty(), viewModel.state.value.undoFailed) + } + + @Test + fun `emits failure when undo operation fails`() = runTest { + // Given + coEvery { undoLastOperation() } returns UndoLastOperation.Error.UndoFailed.left() + + // When + viewModel.submitUndo() + + // Then + assertEquals(Effect.of(Unit), viewModel.state.value.undoFailed) + assertEquals(Effect.empty(), viewModel.state.value.undoSucceeded) + } +} diff --git a/mail-common/src/main/AndroidManifest.xml b/mail-common/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a39e6ee0bf --- /dev/null +++ b/mail-common/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-composer/build.gradle.kts b/mail-composer/build.gradle.kts new file mode 100644 index 0000000000..408903954d --- /dev/null +++ b/mail-composer/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "ch.protonmail.android.mailcomposer" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } +} + +dependencies { + api(project(":mail-composer:dagger")) + api(project(":mail-composer:data")) + api(project(":mail-composer:domain")) + api(project(":mail-composer:presentation")) +} diff --git a/mail-composer/dagger/build.gradle.kts b/mail-composer/dagger/build.gradle.kts new file mode 100644 index 0000000000..f11c47e1f2 --- /dev/null +++ b/mail-composer/dagger/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailcomposer.dagger" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.dagger.hilt.android) + + implementation(libs.proton.core.network) + + implementation(project(":mail-composer:data")) + implementation(project(":mail-composer:domain")) + implementation(project(":mail-composer:presentation")) + + implementation(project(":mail-message:domain")) + implementation(project(":mail-common:data")) +} diff --git a/mail-composer/dagger/src/main/kotlin/ch/protonmail/android/mailcomposer/dagger/MailComposerModule.kt b/mail-composer/dagger/src/main/kotlin/ch/protonmail/android/mailcomposer/dagger/MailComposerModule.kt new file mode 100644 index 0000000000..8a0c898ca2 --- /dev/null +++ b/mail-composer/dagger/src/main/kotlin/ch/protonmail/android/mailcomposer/dagger/MailComposerModule.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.dagger + +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSource +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSourceImpl +import ch.protonmail.android.composer.data.local.ContactsPermissionLocalDataSource +import ch.protonmail.android.composer.data.local.ContactsPermissionLocalDataSourceImpl +import ch.protonmail.android.composer.data.local.DraftStateLocalDataSource +import ch.protonmail.android.composer.data.local.DraftStateLocalDataSourceImpl +import ch.protonmail.android.composer.data.local.MessageExpirationTimeLocalDataSource +import ch.protonmail.android.composer.data.local.MessageExpirationTimeLocalDataSourceImpl +import ch.protonmail.android.composer.data.local.MessagePasswordLocalDataSource +import ch.protonmail.android.composer.data.local.MessagePasswordLocalDataSourceImpl +import ch.protonmail.android.composer.data.local.RoomTransactor +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSource +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSourceImpl +import ch.protonmail.android.composer.data.remote.DraftRemoteDataSource +import ch.protonmail.android.composer.data.remote.DraftRemoteDataSourceImpl +import ch.protonmail.android.composer.data.remote.MessageRemoteDataSource +import ch.protonmail.android.composer.data.remote.MessageRemoteDataSourceImpl +import ch.protonmail.android.composer.data.repository.AttachmentRepositoryImpl +import ch.protonmail.android.composer.data.repository.AttachmentStateRepositoryImpl +import ch.protonmail.android.composer.data.repository.ContactsPermissionRepositoryImpl +import ch.protonmail.android.composer.data.repository.DraftRepositoryImpl +import ch.protonmail.android.composer.data.repository.DraftStateRepositoryImpl +import ch.protonmail.android.composer.data.repository.MessageExpirationTimeRepositoryImpl +import ch.protonmail.android.composer.data.repository.MessagePasswordRepositoryImpl +import ch.protonmail.android.composer.data.repository.MessageRepositoryImpl +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.annotation.IsComposerV2Enabled +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailcomposer.domain.repository.ContactsPermissionRepository +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailcomposer.domain.usecase.featureflags.IsComposerV2FeatureEnabled +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class MailComposerModule { + + @Binds + @Reusable + abstract fun bindsDraftRepository(impl: DraftRepositoryImpl): DraftRepository + + @Binds + @Reusable + abstract fun bindsDraftStateRepository(impl: DraftStateRepositoryImpl): DraftStateRepository + + @Binds + @Reusable + abstract fun provideDraftStateLocalDataSource(impl: DraftStateLocalDataSourceImpl): DraftStateLocalDataSource + + @Binds + @Reusable + abstract fun provideMessageRepository(impl: MessageRepositoryImpl): MessageRepository + + @Binds + @Reusable + abstract fun bindsRoomTransactor(impl: RoomTransactor): Transactor + + @Binds + @Reusable + abstract fun bindsDraftStateRemoteDataSource(impl: DraftRemoteDataSourceImpl): DraftRemoteDataSource + + @Binds + @Reusable + abstract fun bindsMessageRemoteDataSource(impl: MessageRemoteDataSourceImpl): MessageRemoteDataSource + + @Binds + @Reusable + abstract fun bindsAttachmentStateLocalDataSource( + impl: AttachmentStateLocalDataSourceImpl + ): AttachmentStateLocalDataSource + + @Binds + @Reusable + abstract fun bindsAttachmentStateRepository(impl: AttachmentStateRepositoryImpl): AttachmentStateRepository + + @Binds + @Reusable + abstract fun bindsAttachmentRemoteDataSource(impl: AttachmentRemoteDataSourceImpl): AttachmentRemoteDataSource + + @Binds + @Reusable + abstract fun bindsAttachmentRepository(impl: AttachmentRepositoryImpl): AttachmentRepository + + @Binds + @Reusable + abstract fun bindsMessagePasswordLocalDataSource( + impl: MessagePasswordLocalDataSourceImpl + ): MessagePasswordLocalDataSource + + @Binds + @Reusable + abstract fun bindsMessagePasswordRepository(impl: MessagePasswordRepositoryImpl): MessagePasswordRepository + + @Binds + @Reusable + @Suppress("FunctionMaxLength") + abstract fun bindsMessageExpirationTimeLocalDataSource( + impl: MessageExpirationTimeLocalDataSourceImpl + ): MessageExpirationTimeLocalDataSource + + @Binds + @Reusable + abstract fun bindsMessageExpirationTimeRepository( + impl: MessageExpirationTimeRepositoryImpl + ): MessageExpirationTimeRepository + + @Module + @InstallIn(SingletonComponent::class) + object FeatureFlagModule { + + @Provides + @Singleton + @IsComposerV2Enabled + fun provideComposerV2FeatureFlag(isEnabled: IsComposerV2FeatureEnabled) = isEnabled() + } + + @Binds + @Singleton + abstract fun bindContactsPermissionLocalData( + dataSource: ContactsPermissionLocalDataSourceImpl + ): ContactsPermissionLocalDataSource + + @Binds + @Singleton + abstract fun bindContactsPermissionRepository(repo: ContactsPermissionRepositoryImpl): ContactsPermissionRepository +} diff --git a/mail-composer/data/build.gradle.kts b/mail-composer/data/build.gradle.kts new file mode 100644 index 0000000000..5a91595d85 --- /dev/null +++ b/mail-composer/data/build.gradle.kts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") +} + +android { + namespace = "ch.protonmail.android.mailcomposer.data" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + + implementation(libs.bundles.module.data) + implementation(libs.androidx.hilt.work) + + implementation(libs.proton.core.user) + implementation(libs.proton.core.label) + implementation(libs.proton.core.mailSendPreferences) + + implementation(project(":mail-common:data")) + implementation(project(":mail-common:domain")) + implementation(project(":mail-composer:domain")) + implementation(project(":mail-message:data")) + implementation(project(":mail-message:domain")) + implementation(project(":mail-settings:domain")) + implementation(project(":mail-label:domain")) + + testImplementation(libs.bundles.test) + testImplementation(libs.proton.core.testAndroidInstrumented) + testImplementation(libs.androidx.work.runtimeKtx) + testImplementation(project(":test:test-data")) + testImplementation(project(":test:utils")) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/KeyHolderContextExtensions.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/KeyHolderContextExtensions.kt new file mode 100644 index 0000000000..18230d6d6d --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/KeyHolderContextExtensions.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.extension + +import me.proton.core.crypto.common.pgp.EncryptedMessage +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.key.domain.encryptAndSignText +import me.proton.core.key.domain.entity.key.PublicKey +import me.proton.core.key.domain.entity.key.PublicKeyRing +import me.proton.core.key.domain.entity.keyholder.KeyHolderContext +import timber.log.Timber + +/** + * Encrypts the [text] with [forPublicKey] and signs it with [KeyHolderContext]'s Primary Key. + */ +fun KeyHolderContext.encryptAndSignText(text: String, forPublicKey: PublicKey): EncryptedMessage? { + return try { + this.encryptAndSignText(text, PublicKeyRing(listOf(forPublicKey))) + } catch (e: CryptoException) { + Timber.e("Exception in encryptAndSignText", e) + null + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/ListenableFutureExtensions.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/ListenableFutureExtensions.kt new file mode 100644 index 0000000000..ecfe32244e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/extension/ListenableFutureExtensions.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.extension + +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * An alternative to [ListenableFuture]'s `await`, which usage is restricted when outside of [androidx.work] group. + */ +@Suppress("BlockingMethodInNonBlockingContext", "TooGenericExceptionCaught") +suspend fun ListenableFuture.awaitCompletion(): T = suspendCancellableCoroutine { continuation -> + addListener({ + try { + continuation.resume(get()) + } catch (e: Exception) { + continuation.resumeWithException(e) + } + }, Dispatchers.Default.asExecutor()) + + continuation.invokeOnCancellation { + if (!isDone) { + cancel(true) + } + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSource.kt new file mode 100644 index 0000000000..77fda44c82 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSource.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +interface AttachmentStateLocalDataSource { + + suspend fun getAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + + suspend fun getAllAttachmentStatesForMessage(userId: UserId, messageId: MessageId): List + + suspend fun createOrUpdate(state: AttachmentState): Either + + suspend fun createOrUpdate(states: List): Either + + suspend fun delete(state: AttachmentState) + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImpl.kt new file mode 100644 index 0000000000..401fa8a8ef --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImpl.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.data.local.entity.toAttachmentStateEntity +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class AttachmentStateLocalDataSourceImpl @Inject constructor( + draftStateDatabase: DraftStateDatabase +) : AttachmentStateLocalDataSource { + + private val attachmentStateDao = draftStateDatabase.attachmentStateDao() + + override suspend fun getAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = attachmentStateDao.getAttachmentState(userId, messageId, attachmentId) + .let { attachmentState -> attachmentState?.toAttachmentState()?.right() ?: DataError.Local.NoDataCached.left() } + + override suspend fun getAllAttachmentStatesForMessage(userId: UserId, messageId: MessageId): List = + attachmentStateDao.getAllAttachmentStatesForMessage(userId, messageId) + .map { attachmentStateEntity -> attachmentStateEntity.toAttachmentState() } + + override suspend fun createOrUpdate(state: AttachmentState) = createOrUpdate(listOf(state)) + + override suspend fun createOrUpdate(states: List): Either { + return Either.catch { + val mappedStates = states.map { it.toAttachmentStateEntity() } + attachmentStateDao.insertOrUpdate(*mappedStates.toTypedArray()) + }.mapLeft { + Timber.e(it, "Unexpected error writing attachment states to DB") + DataError.Local.Unknown + } + } + + override suspend fun delete(state: AttachmentState) { + attachmentStateDao.delete(state.toAttachmentStateEntity()) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionDataStoreProvider.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionDataStoreProvider.kt new file mode 100644 index 0000000000..73e42154ab --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionDataStoreProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class ContactsPermissionDataStoreProvider @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val Context.contactsPermissionsStore: DataStore by preferencesDataStore( + name = "ContactsPermissionStateStore" + ) + + val contactsPermissionsStore = context.contactsPermissionsStore +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSource.kt new file mode 100644 index 0000000000..7463a8ca16 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSource.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface ContactsPermissionLocalDataSource { + + fun observePermissionDenied(): Flow> + suspend fun trackPermissionDeniedEvent() +} + +class ContactsPermissionLocalDataSourceImpl @Inject constructor( + private val dataStoreProvider: ContactsPermissionDataStoreProvider +) : ContactsPermissionLocalDataSource { + + override fun observePermissionDenied() = dataStoreProvider.contactsPermissionsStore.data.map { prefs -> + prefs[booleanPreferencesKey(SHOULD_STOP_SHOWING_PERMISSION_DIALOG)]?.right() + ?: DataError.Local.NoDataCached.left() + } + + override suspend fun trackPermissionDeniedEvent() { + dataStoreProvider.contactsPermissionsStore.edit { prefs -> + prefs[booleanPreferencesKey(SHOULD_STOP_SHOWING_PERMISSION_DIALOG)] = true + } + } +} + +internal const val SHOULD_STOP_SHOWING_PERMISSION_DIALOG = "HasDeniedContactsPermission" diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateDatabase.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateDatabase.kt new file mode 100644 index 0000000000..412c2dd465 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateDatabase.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import androidx.sqlite.db.SupportSQLiteDatabase +import ch.protonmail.android.composer.data.local.dao.DraftStateDao +import ch.protonmail.android.composer.data.local.dao.MessageExpirationTimeDao +import ch.protonmail.android.composer.data.local.dao.MessagePasswordDao +import ch.protonmail.android.mailmessage.data.local.dao.AttachmentStateDao +import me.proton.core.data.room.db.Database +import me.proton.core.data.room.db.extension.addTableColumn +import me.proton.core.data.room.db.extension.dropTable +import me.proton.core.data.room.db.migration.DatabaseMigration + +interface DraftStateDatabase : Database { + + fun draftStateDao(): DraftStateDao + fun attachmentStateDao(): AttachmentStateDao + fun messagePasswordDao(): MessagePasswordDao + fun messageExpirationTimeDao(): MessageExpirationTimeDao + + companion object { + + val MIGRATION_0: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + // Create DraftState table + database.execSQL("CREATE TABLE IF NOT EXISTS `DraftStateEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`,`messageId`) ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `DraftStateEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `DraftStateEntity` (`userId`, `messageId`)") + } + } + + val MIGRATION_1: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + // Re-create draft state table without ForeignKey on messageId + database.dropTable("DraftStateEntity") + database.execSQL("CREATE TABLE IF NOT EXISTS `DraftStateEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `apiMessageId` TEXT, `state` INTEGER NOT NULL, `action` TEXT NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId` ON `DraftStateEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_DraftStateEntity_userId_messageId` ON `DraftStateEntity` (`userId`, `messageId`)") + } + } + + val MIGRATION_2: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `AttachmentStateEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `apiAttachmentId` TEXT, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`,`messageId`) ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `AttachmentStateEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `AttachmentStateEntity` (`userId`, `messageId`)") + } + } + + val MIGRATION_3: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + database.dropTable("AttachmentStateEntity") + database.execSQL("CREATE TABLE IF NOT EXISTS `AttachmentStateEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `attachmentId` TEXT NOT NULL, `state` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`, `attachmentId`), FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`,`messageId`) ON UPDATE CASCADE ON DELETE CASCADE, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`userId`, `messageId`, `attachmentId`) REFERENCES MessageAttachmentEntity(`userId`, `messageId`, `attachmentId`) ON UPDATE CASCADE ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId` ON `AttachmentStateEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId` ON `AttachmentStateEntity` (`userId`, `messageId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_AttachmentStateEntity_userId_messageId_attachmentId` ON `AttachmentStateEntity` (`userId`, `messageId`, `attachmentId`)") + } + } + + val MIGRATION_4: DatabaseMigration = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + database.addTableColumn("DraftStateEntity", "sendingError", "TEXT") // nullable by default + } + } + + val MIGRATION_5: DatabaseMigration = object : DatabaseMigration { + override fun migrate(database: SupportSQLiteDatabase) { + database.addTableColumn("DraftStateEntity", "sendingStatusConfirmed", "INTEGER NOT NULL", "0") + } + } + + val MIGRATION_6: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `MessagePasswordEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `MessagePasswordEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `MessagePasswordEntity` (`userId`, `messageId`)") + } + } + + val MIGRATION_7: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + database.dropTable("MessagePasswordEntity") + database.execSQL("CREATE TABLE IF NOT EXISTS `MessagePasswordEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `password` TEXT NOT NULL, `passwordHint` TEXT, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`,`messageId`) ON UPDATE CASCADE ON DELETE CASCADE, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId` ON `MessagePasswordEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessagePasswordEntity_userId_messageId` ON `MessagePasswordEntity` (`userId`, `messageId`)") + } + } + + val MIGRATION_8: DatabaseMigration = object : DatabaseMigration { + @Suppress("MaxLineLength") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `MessageExpirationTimeEntity` (`userId` TEXT NOT NULL, `messageId` TEXT NOT NULL, `expiresInSeconds` INTEGER NOT NULL, PRIMARY KEY(`userId`, `messageId`), FOREIGN KEY(`userId`, `messageId`) REFERENCES `MessageEntity`(`userId`,`messageId`) ON UPDATE CASCADE ON DELETE CASCADE, FOREIGN KEY(`userId`) REFERENCES `UserEntity`(`userId`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId` ON `MessageExpirationTimeEntity` (`userId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_MessageExpirationTimeEntity_userId_messageId` ON `MessageExpirationTimeEntity` (`userId`, `messageId`)") + } + } + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSource.kt new file mode 100644 index 0000000000..7ec98e9734 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSource.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId + +interface DraftStateLocalDataSource { + + fun observe(userId: UserId, messageId: MessageId): Flow> + fun observeAll(userId: UserId): Flow> + suspend fun save(state: DraftState): Either + suspend fun delete(state: DraftState) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImpl.kt new file mode 100644 index 0000000000..eb84c8a1c4 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImpl.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailmessage.data.local.entity.toDraftStateEntity +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DraftStateLocalDataSourceImpl @Inject constructor( + draftStateDatabase: DraftStateDatabase +) : DraftStateLocalDataSource { + + private val draftStateDao = draftStateDatabase.draftStateDao() + + override fun observe(userId: UserId, messageId: MessageId): Flow> = + draftStateDao.observeDraftState(userId, messageId).map { + when (it) { + null -> DataError.Local.NoDataCached.left() + else -> it.toDraftState().right() + } + } + + override fun observeAll(userId: UserId): Flow> = draftStateDao.observeAllDraftsState(userId).map { + it.map { it.toDraftState() } + } + + override suspend fun save(state: DraftState): Either { + return Either.catch { + draftStateDao.insertOrUpdate(state.toDraftStateEntity()) + }.mapLeft { + Timber.e("Unexpected error writing draft state to DB. $it") + DataError.Local.Unknown + } + } + + override suspend fun delete(state: DraftState) { + draftStateDao.delete(state.toDraftStateEntity()) + } + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSource.kt new file mode 100644 index 0000000000..5b996f502e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSource.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId + +interface MessageExpirationTimeLocalDataSource { + + suspend fun save(messageExpirationTime: MessageExpirationTime): Either + + suspend fun observe(userId: UserId, messageId: MessageId): Flow +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImpl.kt new file mode 100644 index 0000000000..6208ac6599 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImpl.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.composer.data.local.entity.toEntity +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class MessageExpirationTimeLocalDataSourceImpl @Inject constructor( + database: DraftStateDatabase +) : MessageExpirationTimeLocalDataSource { + + private val messageExpirationTimeDao = database.messageExpirationTimeDao() + + override suspend fun save(messageExpirationTime: MessageExpirationTime): Either { + return Either.catch { + messageExpirationTimeDao.insertOrUpdate(messageExpirationTime.toEntity()) + }.mapLeft { + Timber.e("Unexpected error writing message expiration time to DB.", it) + DataError.Local.DbWriteFailed + } + } + + override suspend fun observe(userId: UserId, messageId: MessageId): Flow = + messageExpirationTimeDao.observe(userId, messageId).mapLatest { it?.toDomainModel() } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSource.kt new file mode 100644 index 0000000000..e313fe1a3f --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSource.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId + +interface MessagePasswordLocalDataSource { + + suspend fun save(messagePassword: MessagePassword): Either + + suspend fun update( + userId: UserId, + messageId: MessageId, + password: String, + passwordHint: String? + ): Either + + suspend fun observe(userId: UserId, messageId: MessageId): Flow + + suspend fun delete(userId: UserId, messageId: MessageId) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImpl.kt new file mode 100644 index 0000000000..767735e4a9 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImpl.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.Either +import ch.protonmail.android.composer.data.local.entity.toEntity +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class MessagePasswordLocalDataSourceImpl @Inject constructor( + database: DraftStateDatabase +) : MessagePasswordLocalDataSource { + + private val messagePasswordDao = database.messagePasswordDao() + + override suspend fun save(messagePassword: MessagePassword): Either { + return Either.catch { + messagePasswordDao.insertOrUpdate(messagePassword.toEntity()) + }.mapLeft { + Timber.e("Unexpected error writing message password to DB.", it) + DataError.Local.Unknown + } + } + + override suspend fun update( + userId: UserId, + messageId: MessageId, + password: String, + passwordHint: String? + ): Either { + return Either.catch { + messagePasswordDao.updatePasswordAndHint(userId, messageId, password, passwordHint) + }.mapLeft { + Timber.e("Unexpected error updating message password in DB.", it) + DataError.Local.Unknown + } + } + + override suspend fun observe(userId: UserId, messageId: MessageId): Flow = + messagePasswordDao.observe(userId, messageId).mapLatest { it?.toDomainModel() } + + override suspend fun delete(userId: UserId, messageId: MessageId) = messagePasswordDao.delete(userId, messageId) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/RoomTransactor.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/RoomTransactor.kt new file mode 100644 index 0000000000..d112d52d27 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/RoomTransactor.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import ch.protonmail.android.mailcomposer.domain.Transactor +import javax.inject.Inject + +/** + * Implementation of [Transactor] interface for Room DB. + * Allows executing the given block in a DB transaction, which ensures both synchronization and atomicity. + * + * [DraftStateDatabase] in injected arbitrarily, any table implementing [me.proton.core.data.room.db.Database] + * will do the same (as this app has a single DB) + */ +class RoomTransactor @Inject constructor( + private val database: DraftStateDatabase +) : Transactor { + + override suspend fun performTransaction(block: suspend () -> T): T = database.inTransaction { block() } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/AttachmentStateConverter.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/AttachmentStateConverter.kt new file mode 100644 index 0000000000..7c7369db69 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/AttachmentStateConverter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.converters + +import androidx.room.TypeConverter +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState + +class AttachmentStateConverters { + + @TypeConverter + fun fromStateToInt(state: AttachmentSyncState?): Int? = state?.value + + @TypeConverter + fun fromIntToAttachmentState(value: Int?): AttachmentSyncState? = value?.let { AttachmentSyncState.from(it) } + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/DraftStateConverters.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/DraftStateConverters.kt new file mode 100644 index 0000000000..584b943586 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/converters/DraftStateConverters.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.converters + +import androidx.room.TypeConverter +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.SendingError +import me.proton.core.util.kotlin.deserialize +import me.proton.core.util.kotlin.serialize + +class DraftStateConverters { + + @TypeConverter + fun fromStringToDraftAction(value: String?): DraftAction? = value?.deserialize() + + @TypeConverter + fun fromDraftActionToString(value: DraftAction?): String? = value?.serialize() + + @TypeConverter + fun fromStateToInt(state: DraftSyncState?): Int? = state?.value + + @TypeConverter + fun fromIntToDraftState(value: Int?): DraftSyncState? = value?.let { DraftSyncState.from(it) } + + @TypeConverter + fun fromStringToSendingError(value: String?): SendingError? = value?.deserialize() + + @TypeConverter + fun fromSendingErrorToString(value: SendingError?): String? = value?.serialize() + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/DraftStateDao.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/DraftStateDao.kt new file mode 100644 index 0000000000..3be62cdb11 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/DraftStateDao.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import ch.protonmail.android.mailmessage.data.local.entity.DraftStateEntity +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.data.room.db.BaseDao +import me.proton.core.domain.entity.UserId + +@Dao +abstract class DraftStateDao : BaseDao() { + + @Query( + """ + SELECT * from DraftStateEntity + WHERE userId = :userId + AND messageId = :messageId + OR apiMessageId = :messageId + """ + ) + abstract fun observeDraftState(userId: UserId, messageId: MessageId): Flow + + @Query( + """ + SELECT * from DraftStateEntity + WHERE userId = :userId + """ + ) + abstract fun observeAllDraftsState(userId: UserId): Flow> +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessageExpirationTimeDao.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessageExpirationTimeDao.kt new file mode 100644 index 0000000000..df64eccbad --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessageExpirationTimeDao.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import ch.protonmail.android.composer.data.local.entity.MessageExpirationTimeEntity +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.data.room.db.BaseDao +import me.proton.core.domain.entity.UserId + +@Dao +abstract class MessageExpirationTimeDao : BaseDao() { + + @Query( + """ + SELECT * from MessageExpirationTimeEntity + WHERE userId = :userId + AND messageId = :messageId + """ + ) + abstract fun observe(userId: UserId, messageId: MessageId): Flow +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessagePasswordDao.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessagePasswordDao.kt new file mode 100644 index 0000000000..442b7c13b9 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/dao/MessagePasswordDao.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import ch.protonmail.android.composer.data.local.entity.MessagePasswordEntity +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.data.room.db.BaseDao +import me.proton.core.domain.entity.UserId + +@Dao +abstract class MessagePasswordDao : BaseDao() { + + @Query( + """ + SELECT * from MessagePasswordEntity + WHERE userId = :userId + AND messageId = :messageId + """ + ) + abstract fun observe(userId: UserId, messageId: MessageId): Flow + + @Query( + """ + DELETE from MessagePasswordEntity + WHERE userId = :userId + AND messageId = :messageId + """ + ) + abstract suspend fun delete(userId: UserId, messageId: MessageId) + + @Query( + """ + UPDATE MessagePasswordEntity + SET password = :password, passwordHint = :passwordHint + WHERE userId = :userId + AND messageId = :messageId + """ + ) + abstract suspend fun updatePasswordAndHint( + userId: UserId, + messageId: MessageId, + password: String, + passwordHint: String? + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessageExpirationTimeEntity.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessageExpirationTimeEntity.kt new file mode 100644 index 0000000000..9c19651687 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessageExpirationTimeEntity.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.data.local.entity.MessageEntity +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import me.proton.core.user.data.entity.UserEntity +import kotlin.time.Duration.Companion.seconds + +@Entity( + primaryKeys = ["userId", "messageId"], + indices = [ + Index("userId"), + Index("userId", "messageId") + ], + foreignKeys = [ + ForeignKey( + entity = UserEntity::class, + parentColumns = ["userId"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["userId", "messageId"], + childColumns = ["userId", "messageId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +data class MessageExpirationTimeEntity( + val userId: UserId, + val messageId: MessageId, + val expiresInSeconds: Long +) { + fun toDomainModel() = MessageExpirationTime(userId, messageId, expiresInSeconds.seconds) +} + +fun MessageExpirationTime.toEntity() = MessageExpirationTimeEntity(userId, messageId, expiresIn.inWholeSeconds) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessagePasswordEntity.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessagePasswordEntity.kt new file mode 100644 index 0000000000..fa6301012e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/local/entity/MessagePasswordEntity.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.data.local.entity.MessageEntity +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import me.proton.core.user.data.entity.UserEntity + +@Entity( + primaryKeys = ["userId", "messageId"], + indices = [ + Index("userId"), + Index("userId", "messageId") + ], + foreignKeys = [ + ForeignKey( + entity = UserEntity::class, + parentColumns = ["userId"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["userId", "messageId"], + childColumns = ["userId", "messageId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +data class MessagePasswordEntity( + val userId: UserId, + val messageId: MessageId, + val password: String, + val passwordHint: String? +) { + fun toDomainModel() = MessagePassword(userId, messageId, password, passwordHint) +} + +fun MessagePassword.toEntity() = MessagePasswordEntity(userId, messageId, password, passwordHint) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentApi.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentApi.kt new file mode 100644 index 0000000000..ff3f88aa70 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentApi.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import ch.protonmail.android.composer.data.remote.response.UploadAttachmentResponse +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.http.DELETE +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path + +interface AttachmentApi : BaseRetrofitApi { + + @Suppress("LongParameterList") + @Multipart + @POST("mail/v4/attachments") + suspend fun uploadAttachment( + @Part("Filename") filename: RequestBody, + @Part("MessageID") messageID: RequestBody, + @Part("MIMEType") mimeType: RequestBody, + @Part keyPackets: MultipartBody.Part, + @Part dataPacket: MultipartBody.Part, + @Part signature: MultipartBody.Part + ): UploadAttachmentResponse + + @DELETE("mail/v4/attachments/{AttachmentID}") + suspend fun deleteAttachment(@Path("AttachmentID") attachmentId: String): ResponseBody + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSource.kt new file mode 100644 index 0000000000..4e4910639b --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSource.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.Either +import ch.protonmail.android.composer.data.remote.response.UploadAttachmentResponse +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import me.proton.core.domain.entity.UserId + +interface AttachmentRemoteDataSource { + + suspend fun uploadAttachment( + userId: UserId, + uploadAttachmentModel: UploadAttachmentModel + ): Either + + /** + * Delete the attachment for the given [userId] and [attachmentId]. + */ + fun deleteAttachmentFromDraft(userId: UserId, attachmentId: AttachmentId) + + /** + * Cancel the attachment upload for the given [attachmentId]. + */ + fun cancelAttachmentUpload(attachmentId: AttachmentId) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..4869b40b22 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImpl.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import androidx.work.WorkManager +import arrow.core.Either +import ch.protonmail.android.composer.data.remote.response.UploadAttachmentResponse +import ch.protonmail.android.mailcommon.data.mapper.toEither +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject + +class AttachmentRemoteDataSourceImpl @Inject constructor( + private val enqueuer: Enqueuer, + private val workManager: WorkManager, + private val apiProvider: ApiProvider +) : AttachmentRemoteDataSource { + + override suspend fun uploadAttachment( + userId: UserId, + uploadAttachmentModel: UploadAttachmentModel + ): Either { + val octetStream = "application/octet-stream".toMediaType() + val keyPacket = MultipartBody.Part.createFormData( + name = "KeyPackets", + filename = "KeyPackets", + uploadAttachmentModel.keyPacket.toRequestBody(octetStream) + ) + val dataPacket = MultipartBody.Part.createFormData( + name = "DataPacket", + filename = uploadAttachmentModel.fileName, + uploadAttachmentModel.attachment.asRequestBody(uploadAttachmentModel.mimeType.toMediaType()) + ) + val signature = MultipartBody.Part.createFormData( + name = "Signature", + filename = "Signature", + uploadAttachmentModel.signature.toRequestBody(octetStream) + ) + + return apiProvider.get(userId).invoke { + uploadAttachment( + filename = uploadAttachmentModel.fileName.toRequestBody(), + messageID = uploadAttachmentModel.messageId.id.toRequestBody(), + mimeType = uploadAttachmentModel.mimeType.toRequestBody(), + keyPackets = keyPacket, + dataPacket = dataPacket, + signature = signature + ) + }.toEither() + } + + override fun deleteAttachmentFromDraft(userId: UserId, attachmentId: AttachmentId) { + enqueuer.enqueueUniqueWork( + userId = userId, + workerId = attachmentId.id, + params = DeleteAttachmentWorker.params(userId, attachmentId) + ) + } + + override fun cancelAttachmentUpload(attachmentId: AttachmentId) { + workManager.getWorkInfosForUniqueWork(attachmentId.id).get().forEach { + workManager.cancelUniqueWork(attachmentId.id) + } + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorker.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorker.kt new file mode 100644 index 0000000000..28fcdc6291 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorker.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import ch.protonmail.android.mailcommon.domain.util.requireNotBlank +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.ApiResult +import me.proton.core.network.domain.isRetryable +import timber.log.Timber + +@HiltWorker +class DeleteAttachmentWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val apiProvider: ApiProvider +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + val userId = requireNotBlank(inputData.getString(RawUserIdKey), fieldName = "User id") + val attachmentId = requireNotBlank(inputData.getString(RawAttachmentIdKey), fieldName = "Attachment id") + + val result = apiProvider.get(UserId(userId)).invoke { + deleteAttachment(attachmentId = attachmentId) + } + + return when (result) { + is ApiResult.Success -> Result.success() + is ApiResult.Error -> { + if (result.isRetryable()) { + Result.retry() + } else { + Timber.e("DeleteAttachmentWorker failed: $result") + Result.failure() + } + } + } + } + + companion object { + + const val RawUserIdKey = "userId" + const val RawAttachmentIdKey = "attachmentId" + + fun params(userId: UserId, attachmentId: AttachmentId) = mapOf( + RawUserIdKey to userId.id, + RawAttachmentIdKey to attachmentId.id + ) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftActionExt.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftActionExt.kt new file mode 100644 index 0000000000..d9a50f4d23 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftActionExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import ch.protonmail.android.mailmessage.domain.model.DraftAction + +fun DraftAction.toApiInt() = when (this) { + is DraftAction.Compose, + is DraftAction.PrefillForShare, + is DraftAction.ComposeToAddresses -> -1 + is DraftAction.Reply -> 0 + is DraftAction.ReplyAll -> 1 + is DraftAction.Forward -> 2 +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftApi.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftApi.kt new file mode 100644 index 0000000000..89d938fe81 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftApi.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import ch.protonmail.android.composer.data.remote.resource.CreateDraftBody +import ch.protonmail.android.composer.data.remote.resource.UpdateDraftBody +import ch.protonmail.android.composer.data.remote.response.SaveDraftResponse +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path + +interface DraftApi : BaseRetrofitApi { + + @POST("mail/v4/messages") + suspend fun createDraft(@Body body: CreateDraftBody): SaveDraftResponse + + @PUT("mail/v4/messages/{messageId}") + suspend fun updateDraft(@Path("messageId") messageId: String, @Body body: UpdateDraftBody): SaveDraftResponse +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSource.kt new file mode 100644 index 0000000000..50afe9ea8e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSource.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import me.proton.core.domain.entity.UserId + +interface DraftRemoteDataSource { + + suspend fun create( + userId: UserId, + messageWithBody: MessageWithBody, + action: DraftAction + ): Either + + suspend fun update(userId: UserId, messageWithBody: MessageWithBody): Either +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..6e9db5527f --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceImpl.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.Either +import arrow.core.left +import ch.protonmail.android.composer.data.remote.resource.CreateDraftBody +import ch.protonmail.android.composer.data.remote.resource.DraftMessageResource +import ch.protonmail.android.composer.data.remote.resource.UpdateDraftBody +import ch.protonmail.android.mailcommon.data.mapper.toEither +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.data.remote.resource.RecipientResource +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import me.proton.core.util.kotlin.toInt +import javax.inject.Inject + +class DraftRemoteDataSourceImpl @Inject constructor( + private val apiProvider: ApiProvider +) : DraftRemoteDataSource { + + override suspend fun create( + userId: UserId, + messageWithBody: MessageWithBody, + action: DraftAction + ): Either { + val body = CreateDraftBody( + messageWithBody.toDraftMessageResource(), + action.getParentMessageId()?.id, + action.toApiInt(), + messageWithBody.buildAttachmentKeyPackets() + ) + + if (body.message.body.isBlank()) { + /* + * The API doesn't accept being called with an empty body. + * Body is empty when the draft was just created and any data (subject, recipients..) was added + * but the body wasn't. In order to avoid adding ad-hoc logic to create an encrypted empty-string + * body, we block the draft creation (triggered by the automatic sync) from happening till a body was added + */ + return DataError.Remote.CreateDraftRequestNotPerformed.left() + } + + return apiProvider.get(userId).invoke { + createDraft(body).message.toMessageWithBody(userId) + }.toEither() + } + + override suspend fun update( + userId: UserId, + messageWithBody: MessageWithBody + ): Either { + val messageId = messageWithBody.message.messageId + val body = UpdateDraftBody( + messageWithBody.toDraftMessageResource(), + messageWithBody.buildAttachmentKeyPackets() + ) + + return apiProvider.get(userId).invoke { + updateDraft(messageId.id, body).message.toMessageWithBody(userId) + }.toEither() + } + + private fun MessageWithBody.buildAttachmentKeyPackets(): Map = + this.messageBody.attachments.filter { it.keyPackets != null }.associate { + it.attachmentId.id to it.keyPackets!! + } + + private fun MessageWithBody.toDraftMessageResource() = DraftMessageResource( + subject = this.message.subject, + this.message.unread.toInt(), + with(this.message.sender) { RecipientResource(address, name) }, + this.message.toList.map { RecipientResource(it.address, it.name) }, + this.message.ccList.map { RecipientResource(it.address, it.name) }, + this.message.bccList.map { RecipientResource(it.address, it.name) }, + this.message.externalId, + this.message.flags, + this.messageBody.body, + this.messageBody.mimeType.value + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageApi.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageApi.kt new file mode 100644 index 0000000000..0d3fa60ff7 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageApi.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import ch.protonmail.android.composer.data.remote.resource.SendMessageBody +import ch.protonmail.android.composer.data.remote.response.SendMessageResponse +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Path + +interface MessageApi : BaseRetrofitApi { + + @POST("mail/v4/messages/{draftId}") + suspend fun send(@Path("draftId") draftId: String, @Body body: SendMessageBody): SendMessageResponse + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSource.kt new file mode 100644 index 0000000000..4205a01a90 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSource.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.Either +import ch.protonmail.android.composer.data.remote.resource.SendMessageBody +import ch.protonmail.android.composer.data.remote.response.SendMessageResponse +import ch.protonmail.android.mailcommon.domain.model.DataError +import me.proton.core.domain.entity.UserId + +interface MessageRemoteDataSource { + + suspend fun send( + userId: UserId, + draftId: String, + body: SendMessageBody + ): Either +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSourceImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..4548187516 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/MessageRemoteDataSourceImpl.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.composer.data.remote.resource.SendMessageBody +import ch.protonmail.android.composer.data.remote.response.SendMessageResponse +import ch.protonmail.android.mailcommon.data.mapper.toEither +import ch.protonmail.android.mailcommon.domain.model.DataError +import me.proton.core.domain.entity.UserId +import me.proton.core.network.data.ApiProvider +import javax.inject.Inject + +class MessageRemoteDataSourceImpl @Inject constructor( + private val apiProvider: ApiProvider +) : MessageRemoteDataSource { + + override suspend fun send( + userId: UserId, + draftId: String, + body: SendMessageBody + ): Either = either { + apiProvider.get(userId).invoke { + send(draftId, body) + }.toEither().bind() + } + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorker.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorker.kt new file mode 100644 index 0000000000..4c5b0cf919 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorker.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import ch.protonmail.android.composer.data.usecase.SendMessage +import ch.protonmail.android.mailcommon.domain.util.requireNotBlank +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.domain.entity.UserId +import timber.log.Timber + +@HiltWorker +internal class SendMessageWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val sendMessage: SendMessage, + private val draftStateRepository: DraftStateRepository, + private val updateDraftStateForError: UpdateDraftStateForError +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + val userId = UserId(requireNotBlank(inputData.getString(RawUserIdKey), fieldName = "User id")) + val messageId = MessageId(requireNotBlank(inputData.getString(RawMessageIdKey), fieldName = "Message id")) + + return sendMessage(userId, messageId).fold( + ifLeft = { + Timber + .tag("SendMessageWorker") + .e("API error sending message - error: %s - messageId: %s", it, messageId) + + updateDraftStateForError(userId, messageId, DraftSyncState.ErrorSending, it.toSendingError()) + Result.failure() + }, + ifRight = { + draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Sent) + Result.success() + } + ) + } + + companion object { + + internal const val RawUserIdKey = "sendMessageWorkParamUserId" + internal const val RawMessageIdKey = "sendMessageWorkParamMessageId" + + fun params(userId: UserId, messageId: MessageId) = mapOf( + RawUserIdKey to userId.id, + RawMessageIdKey to messageId.id + ) + + fun id(messageId: MessageId): String = "SendMessageWorker-${messageId.id}" + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentModel.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentModel.kt new file mode 100644 index 0000000000..fc399cec5d --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentModel.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.crypto.common.pgp.EncryptedFile +import me.proton.core.crypto.common.pgp.KeyPacket +import me.proton.core.crypto.common.pgp.Unarmored + +data class UploadAttachmentModel( + val messageId: MessageId, + val fileName: String, + val mimeType: String, + val keyPacket: KeyPacket, + val attachment: EncryptedFile, + val signature: Unarmored +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorker.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorker.kt new file mode 100644 index 0000000000..1a1d42c03a --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorker.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import ch.protonmail.android.composer.data.usecase.AttachmentUploadError +import ch.protonmail.android.composer.data.usecase.UploadAttachments +import ch.protonmail.android.mailcommon.domain.model.isMessageAlreadySentAttachmentError +import ch.protonmail.android.mailcommon.domain.util.requireNotBlank +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.domain.entity.UserId +import timber.log.Timber + +@HiltWorker +class UploadAttachmentsWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val uploadAttachments: UploadAttachments, + private val updateDraftStateForError: UpdateDraftStateForError +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + val userId = UserId(requireNotBlank(inputData.getString(RawUserIdKey), fieldName = "User id")) + val messageId = MessageId(requireNotBlank(inputData.getString(RawMessageIdKey), fieldName = "Message ids")) + + Timber.d("UploadAttachmentsWorker doWork") + + return uploadAttachments(userId, messageId).fold( + ifLeft = { + Timber.e("UploadAttachmentsWorker doWork failed: $it") + updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadAttachments, it.toSendingError()) + Result.failure() + }, + ifRight = { Result.success() } + ) + } + + private fun AttachmentUploadError.toSendingError() = when (this) { + is AttachmentUploadError.UploadFailed -> { + if (this.remoteDataError.isMessageAlreadySentAttachmentError()) { + SendingError.MessageAlreadySent + } else { + null + } + } + else -> null + } + + companion object { + + internal const val RawUserIdKey = "uploadAttachmentWorkParamUserId" + internal const val RawMessageIdKey = "uploadAttachmentWorkParamMessageId" + + fun params(userId: UserId, messageId: MessageId) = mapOf( + RawUserIdKey to userId.id, + RawMessageIdKey to messageId.id + ) + + fun id(messageId: MessageId): String = "UploadAttachmentWorker-${messageId.id}" + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorker.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorker.kt new file mode 100644 index 0000000000..6ed1b72ca7 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorker.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import ch.protonmail.android.composer.data.extension.awaitCompletion +import ch.protonmail.android.composer.data.usecase.UploadDraft +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.isMessageAlreadySentDraftError +import ch.protonmail.android.mailcommon.domain.util.requireNotBlank +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.domain.entity.UserId + +@HiltWorker +internal class UploadDraftWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted private val workerParameters: WorkerParameters, + private val workManager: WorkManager, + private val uploadDraft: UploadDraft, + private val updateDraftStateForError: UpdateDraftStateForError +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + val userId = UserId(requireNotBlank(inputData.getString(RawUserIdKey), fieldName = "User id")) + val messageId = MessageId(requireNotBlank(inputData.getString(RawMessageIdKey), fieldName = "Message ids")) + + if (shouldAwaitPreviousDraftUploadExecution(messageId)) return Result.retry() + + return uploadDraft(userId, messageId).fold( + ifLeft = { + updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadDraft, it.toSendingError()) + return when (it) { + is DataError.Remote.Http -> if (it.isRetryable) Result.retry() else Result.failure() + else -> Result.failure() + } + }, + ifRight = { + Result.success() + } + ) + } + + // Handles the case where the Draft upload is called as part of the Sending flow, and an existing UploadDraftWork + // job triggered from the standard Upload flow is already enqueued, blocked or running. + // In case it's enqueued or blocked, the job gets cancelled and the current job re-scheduled (via Retry). + // If it's running we wait for the run to complete without cancellation. In any other case the worker runs directly. + private suspend fun shouldAwaitPreviousDraftUploadExecution(messageId: MessageId): Boolean { + if (!workerParameters.tags.contains(sendId(messageId))) return false + + val uploadDraftUniqueWorkName = id(messageId) + val uniqueWorkInfo = workManager.getWorkInfosForUniqueWork(uploadDraftUniqueWorkName) + .awaitCompletion() + .firstOrNull() + ?: return false + + return when (uniqueWorkInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + workManager.cancelUniqueWork(uploadDraftUniqueWorkName).await() + return true + } + + WorkInfo.State.RUNNING -> true + WorkInfo.State.SUCCEEDED, + WorkInfo.State.FAILED, + WorkInfo.State.CANCELLED -> false + } + } + + private fun DataError.toSendingError() = when { + this.isMessageAlreadySentDraftError() -> SendingError.MessageAlreadySent + else -> null + } + + companion object { + + internal const val RawUserIdKey = "syncDraftWorkParamUserId" + internal const val RawMessageIdKey = "syncDraftWorkParamMessageId" + + fun params(userId: UserId, messageId: MessageId) = mapOf( + RawUserIdKey to userId.id, + RawMessageIdKey to messageId.id + ) + + fun id(messageId: MessageId): String = "SyncDraftWorker-${messageId.id}" + fun sendId(messageId: MessageId): String = "SendMessageWorker-${messageId.id}" + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/CreateDraftBody.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/CreateDraftBody.kt new file mode 100644 index 0000000000..e85b302c46 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/CreateDraftBody.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import me.proton.core.crypto.common.pgp.Armored + +@Serializable +data class CreateDraftBody( + @SerialName("Message") + val message: DraftMessageResource, + @SerialName("ParentID") + val parentId: String?, + @SerialName("Action") + val action: Int, + @SerialName("AttachmentKeyPackets") + val attachmentKeyPackets: Map +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/DraftMessageResource.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/DraftMessageResource.kt new file mode 100644 index 0000000000..a532d351f1 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/DraftMessageResource.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.resource + +import ch.protonmail.android.mailmessage.data.remote.resource.RecipientResource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DraftMessageResource( + @SerialName("Subject") + val subject: String, + @SerialName("Unread") + val unread: Int, + @SerialName("Sender") + val sender: RecipientResource, + @SerialName("ToList") + val toList: List, + @SerialName("CCList") + val ccList: List, + @SerialName("BCCList") + val bccList: List, + @SerialName("ExternalID") + val externalId: String?, + @SerialName("Flags") + val flags: Long, + @SerialName("Body") + val body: String, + @SerialName("MIMEType") + val mimeType: String +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessageBody.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessageBody.kt new file mode 100644 index 0000000000..8f4e6b9f8d --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessageBody.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendMessageBody( + + @SerialName("ExpiresIn") + val expiresIn: Long, + + @SerialName("AutoSaveContacts") + val autoSaveContacts: Int, + + @SerialName("Packages") + val packages: List + +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessagePackage.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessagePackage.kt new file mode 100644 index 0000000000..26ad082c92 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/SendMessagePackage.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import me.proton.core.mailmessage.domain.entity.Email +import me.proton.core.mailsettings.domain.entity.PackageType + +@Serializable +data class SendMessagePackage( + @SerialName("Addresses") + val addresses: Map, + @SerialName("MIMEType") + val mimeType: String, + @SerialName("Body") + val body: String, + @SerialName("Type") + val type: Int, // the package global type is a logical OR of the types of all the addresses for this package + @SerialName("BodyKey") + val bodyKey: Key? = null, // include only if there are cleartext recipients + @SerialName("AttachmentKeys") // a map of an attachment id and a session key + val attachmentKeys: Map? = null // include only if there are cleartext recipients +) { + + /** + * Structure representing payload for each recipient, depending on its type. + */ + @Serializable + sealed class Address( + @SerialName("Type") + val type: Int + ) { + + @Serializable + data class Internal( + @SerialName("Signature") + val signature: Int, + @SerialName("BodyKeyPacket") + val bodyKeyPacket: String, + @SerialName("AttachmentKeyPackets") + val attachmentKeyPackets: Map + ) : Address(PackageType.ProtonMail.type) + + @Serializable + data class ExternalEncrypted( + @SerialName("Signature") + val signature: Int, + @SerialName("BodyKeyPacket") + val bodyKeyPacket: String + ) : Address(PackageType.PgpMime.type) + + @Serializable + data class ExternalSigned( + @SerialName("Signature") + val signature: Int + ) : Address(PackageType.ClearMime.type) + + @Serializable + data class ExternalCleartext( + @SerialName("Signature") + val signature: Int + ) : Address(PackageType.Cleartext.type) + + @Serializable + data class EncryptedOutside( + @SerialName("BodyKeyPacket") + val bodyKeyPacket: String, + @SerialName("AttachmentKeyPackets") + val attachmentKeyPackets: Map, + @SerialName("Token") + val token: String, + @SerialName("EncToken") + val encToken: String, + @SerialName("Auth") + val auth: Auth, + @SerialName("PasswordHint") + val passwordHint: String?, + @SerialName("Signature") + val signature: Int + ) : Address(PackageType.EncryptedOutside.type) + } + + @Serializable + data class Key( + @SerialName("Key") + val key: String, + @SerialName("Algorithm") + val algorithm: String + ) + + @Serializable + data class Auth( + @SerialName("ModulusID") + val modulusId: String, + @SerialName("Version") + val version: Int, + @SerialName("Salt") + val salt: String, + @SerialName("Verifier") + val verifier: String + ) + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/UpdateDraftBody.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/UpdateDraftBody.kt new file mode 100644 index 0000000000..64ce26d73d --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/resource/UpdateDraftBody.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import me.proton.core.crypto.common.pgp.Armored + +@Serializable +data class UpdateDraftBody( + @SerialName("Message") + val message: DraftMessageResource, + @SerialName("AttachmentKeyPackets") + val attachmentKeyPackets: Map +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SaveDraftResponse.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SaveDraftResponse.kt new file mode 100644 index 0000000000..ee66ee3258 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SaveDraftResponse.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.response + +import ch.protonmail.android.mailmessage.data.remote.resource.MessageWithBodyResource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SaveDraftResponse( + @SerialName("Code") + val code: Int, + @SerialName("Message") + val message: MessageWithBodyResource +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SendMessageResponse.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SendMessageResponse.kt new file mode 100644 index 0000000000..a94e319570 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/SendMessageResponse.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.response + +import ch.protonmail.android.mailmessage.data.remote.resource.MessageWithBodyResource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendMessageResponse( + @SerialName("Code") + val code: Int, + + @SerialName("Sent") + val message: MessageWithBodyResource +) + diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/UploadAttachmentResponse.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/UploadAttachmentResponse.kt new file mode 100644 index 0000000000..d8985e8106 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/remote/response/UploadAttachmentResponse.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote.response + +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentResource +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UploadAttachmentResponse( + @SerialName("Code") + val code: Int, + @SerialName("Attachment") + val attachment: AttachmentResource +) diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImpl.kt new file mode 100644 index 0000000000..33173ada0f --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImpl.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSource +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.data.local.AttachmentLocalDataSource +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class AttachmentRepositoryImpl @Inject constructor( + private val attachmentStateLocalDataSource: AttachmentStateLocalDataSource, + private val attachmentRemoteDataSource: AttachmentRemoteDataSource, + private val attachmentLocalDataSource: AttachmentLocalDataSource +) : AttachmentRepository { + + override suspend fun deleteAttachment( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = either { + val attachmentState = attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId).bind() + when (attachmentState.state) { + AttachmentSyncState.ExternalUploaded, + AttachmentSyncState.Uploaded -> attachmentRemoteDataSource.deleteAttachmentFromDraft(userId, attachmentId) + + else -> attachmentRemoteDataSource.cancelAttachmentUpload(attachmentId) + } + when (attachmentState.state) { + AttachmentSyncState.ExternalUploaded, + AttachmentSyncState.External -> + attachmentLocalDataSource + .deleteAttachment(userId, messageId, attachmentId) + .bind() + + else -> attachmentLocalDataSource.deleteAttachmentWithFile(userId, messageId, attachmentId).bind() + } + } + + override suspend fun createAttachment( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId, + fileName: String, + mimeType: String, + content: ByteArray + ): Either = either { + + attachmentLocalDataSource.upsertAttachment( + userId, + messageId, + attachmentId, + fileName, + mimeType, + content + ).bind() + + attachmentStateLocalDataSource.createOrUpdate( + AttachmentState( + userId, + messageId, + attachmentId, + AttachmentSyncState.Local + ) + ).bind() + + return Unit.right() + } + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImpl.kt new file mode 100644 index 0000000000..ee09dba39e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImpl.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class AttachmentStateRepositoryImpl @Inject constructor( + private val localDataSource: AttachmentStateLocalDataSource +) : AttachmentStateRepository { + + override suspend fun getAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = localDataSource.getAttachmentState(userId, messageId, attachmentId) + + override suspend fun getAllAttachmentStatesForMessage(userId: UserId, messageId: MessageId): List = + localDataSource.getAllAttachmentStatesForMessage(userId, messageId) + + override suspend fun createOrUpdateLocalState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = localDataSource + .createOrUpdate(AttachmentState(userId, messageId, attachmentId, AttachmentSyncState.Local)) + + + override suspend fun createOrUpdateLocalStates( + userId: UserId, + messageId: MessageId, + attachmentIds: List, + syncState: AttachmentSyncState + ): Either { + return attachmentIds + .map { AttachmentState(userId, messageId, it, syncState) } + .let { localDataSource.createOrUpdate(it) } + } + + override suspend fun setAttachmentToUploadState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = either { + val attachmentState = localDataSource.getAttachmentState(userId, messageId, attachmentId).bind() + localDataSource.createOrUpdate(attachmentState.copy(state = AttachmentSyncState.Uploaded)) + } + + override suspend fun deleteAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = either { + val attachmentState = localDataSource.getAttachmentState(userId, messageId, attachmentId).bind() + localDataSource.delete(attachmentState) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/ContactsPermissionRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/ContactsPermissionRepositoryImpl.kt new file mode 100644 index 0000000000..a9366f0224 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/ContactsPermissionRepositoryImpl.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import ch.protonmail.android.composer.data.local.ContactsPermissionLocalDataSource +import ch.protonmail.android.mailcomposer.domain.repository.ContactsPermissionRepository +import javax.inject.Inject + +class ContactsPermissionRepositoryImpl @Inject constructor( + private val dataSource: ContactsPermissionLocalDataSource +) : ContactsPermissionRepository { + + override fun observePermissionDenied() = dataSource.observePermissionDenied() + + override suspend fun trackPermissionDenied() { + dataSource.trackPermissionDeniedEvent() + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImpl.kt new file mode 100644 index 0000000000..eab5795474 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImpl.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import ch.protonmail.android.composer.data.remote.UploadAttachmentsWorker +import ch.protonmail.android.composer.data.remote.UploadDraftWorker +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DraftRepositoryImpl @Inject constructor( + private val enqueuer: Enqueuer, + private val workerManager: WorkManager, + private val draftUploadTracker: DraftUploadTracker +) : DraftRepository { + + override suspend fun upload(userId: UserId, messageId: MessageId) { + if (draftUploadTracker.uploadRequired(userId, messageId)) { + val uniqueWorkId = UploadDraftWorker.id(messageId) + + enqueuer.enqueueUniqueWork( + userId = userId, + workerId = uniqueWorkId, + params = UploadDraftWorker.params(userId, messageId), + existingWorkPolicy = ExistingWorkPolicy.KEEP + ) + } else { + Timber.v("Draft: Upload skipped for $messageId") + } + } + + override suspend fun forceUpload(userId: UserId, messageId: MessageId) { + Timber.d("Draft force upload: Adding work to upload $messageId") + val uniqueWorkId = UploadDraftWorker.id(messageId) + + enqueuer.enqueueInChain( + userId = userId, + uniqueWorkId = uniqueWorkId, + params1 = UploadDraftWorker.params(userId, messageId), + params2 = UploadAttachmentsWorker.params(userId, messageId), + existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE + ) + } + + override fun cancelUploadDraft(messageId: MessageId) { + val uniqueWorkId = UploadDraftWorker.id(messageId) + workerManager.cancelUniqueWork(uniqueWorkId) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImpl.kt new file mode 100644 index 0000000000..fb57a9235e --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImpl.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.getOrElse +import ch.protonmail.android.composer.data.local.DraftStateLocalDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.OutboxStates +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DraftStateRepositoryImpl @Inject constructor( + private val localDataSource: DraftStateLocalDataSource +) : DraftStateRepository { + + override fun observe(userId: UserId, messageId: MessageId): Flow> = + localDataSource.observe(userId, messageId) + + override fun observeAll(userId: UserId): Flow> = localDataSource.observeAll(userId) + + override suspend fun createOrUpdateLocalState( + userId: UserId, + messageId: MessageId, + action: DraftAction + ): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().getOrElse { + DraftState(userId, messageId, null, DraftSyncState.Local, action, null, false) + } + val updatedState = draftState.copy(state = DraftSyncState.Local) + localDataSource.save(updatedState) + } + + override suspend fun updateDraftSyncState( + userId: UserId, + messageId: MessageId, + syncState: DraftSyncState + ): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().bind() + localDataSource.save( + draftState.copy( + state = validateUpdateDraftSyncState(draftState.state, syncState) + ) + ) + } + + override suspend fun updateConfirmDraftSendingStatus( + userId: UserId, + messageId: MessageId, + sendingStatusConfirmed: Boolean + ): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().bind() + localDataSource.save(draftState.copy(sendingStatusConfirmed = sendingStatusConfirmed)) + } + + override suspend fun updateSendingError( + userId: UserId, + messageId: MessageId, + sendingError: SendingError? + ): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().bind() + localDataSource.save(draftState.copy(sendingError = sendingError)) + } + + override suspend fun deleteDraftState(userId: UserId, messageId: MessageId): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().bind() + localDataSource.delete(draftState) + } + + override suspend fun updateApiMessageIdAndSetSyncedState( + userId: UserId, + messageId: MessageId, + apiMessageId: MessageId + ): Either = either { + val draftState = localDataSource.observe(userId, messageId).first().bind() + val updatedState = draftState.copy( + apiMessageId = apiMessageId, + state = validateUpdateDraftSyncState(draftState.state, DraftSyncState.Synchronized) + ) + localDataSource.save(updatedState) + } + + private fun validateUpdateDraftSyncState(fromState: DraftSyncState, toState: DraftSyncState): DraftSyncState { + return if (validStateTransition(fromState, toState)) toState else { + Timber.i( + "Ignored state transition from: ${fromState.name} to: ${toState.name}" + ) + fromState + } + } + + private fun validStateTransition(from: DraftSyncState, to: DraftSyncState): Boolean = + !(OutboxStates.isSendingState(from) && to == DraftSyncState.Synchronized) + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImpl.kt new file mode 100644 index 0000000000..c7f7958e76 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImpl.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.Either +import ch.protonmail.android.composer.data.local.MessageExpirationTimeLocalDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class MessageExpirationTimeRepositoryImpl @Inject constructor( + private val messageExpirationTimeLocalDataSource: MessageExpirationTimeLocalDataSource +) : MessageExpirationTimeRepository { + + override suspend fun saveMessageExpirationTime( + messageExpirationTime: MessageExpirationTime + ): Either = messageExpirationTimeLocalDataSource.save(messageExpirationTime) + + override suspend fun observeMessageExpirationTime( + userId: UserId, + messageId: MessageId + ): Flow = messageExpirationTimeLocalDataSource.observe(userId, messageId) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImpl.kt new file mode 100644 index 0000000000..c39a95c9f9 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImpl.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import ch.protonmail.android.composer.data.local.MessagePasswordLocalDataSource +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class MessagePasswordRepositoryImpl @Inject constructor( + private val messagePasswordLocalDataSource: MessagePasswordLocalDataSource +) : MessagePasswordRepository { + + override suspend fun saveMessagePassword(messagePassword: MessagePassword) = + messagePasswordLocalDataSource.save(messagePassword) + + override suspend fun updateMessagePassword( + userId: UserId, + messageId: MessageId, + password: String, + passwordHint: String? + ) = messagePasswordLocalDataSource.update(userId, messageId, password, passwordHint) + + override suspend fun observeMessagePassword(userId: UserId, messageId: MessageId): Flow = + messagePasswordLocalDataSource.observe(userId, messageId) + + override suspend fun deleteMessagePassword(userId: UserId, messageId: MessageId) = + messagePasswordLocalDataSource.delete(userId, messageId) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageRepositoryImpl.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageRepositoryImpl.kt new file mode 100644 index 0000000000..25d1834c6a --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/repository/MessageRepositoryImpl.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import androidx.work.ExistingWorkPolicy +import arrow.core.Either +import arrow.core.right +import ch.protonmail.android.composer.data.remote.SendMessageWorker +import ch.protonmail.android.composer.data.remote.UploadAttachmentsWorker +import ch.protonmail.android.composer.data.remote.UploadDraftWorker +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.mailmessage.data.local.MessageLocalDataSource +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class MessageRepositoryImpl @Inject constructor( + private val messageLocalDataSource: MessageLocalDataSource, + private val enqueuer: Enqueuer +) : MessageRepository { + + override suspend fun send(userId: UserId, messageId: MessageId) { + Timber.d("MessageRepository send $messageId") + + enqueuer.enqueueInChain( + userId = userId, + uniqueWorkId = UploadDraftWorker.sendId(messageId), + params1 = UploadDraftWorker.params(userId, messageId), + params2 = UploadAttachmentsWorker.params(userId, messageId), + params3 = SendMessageWorker.params(userId, messageId), + existingWorkPolicy = ExistingWorkPolicy.KEEP + ) + } + + override suspend fun moveMessageFromDraftsToSent(userId: UserId, messageId: MessageId): Either { + // optimistically move message to "Sent folder", but only in local DB (for the time of sending) + return messageLocalDataSource.relabelMessages( + userId, + listOf(messageId), + labelIdsToAdd = setOf(SystemLabelId.Sent.labelId, SystemLabelId.AllSent.labelId), + labelIdsToRemove = setOf(SystemLabelId.Drafts.labelId, SystemLabelId.AllDrafts.labelId) + ).map { Unit.right() } + } + + override suspend fun moveMessageBackFromSentToDrafts(userId: UserId, messageId: MessageId) { + + // move message back from "Sent folder" to "Drafts", but only in local DB (to rollback the optimistic move + // to "Sent") + messageLocalDataSource.relabelMessages( + userId, + listOf(messageId), + labelIdsToAdd = setOf(SystemLabelId.Drafts.labelId, SystemLabelId.AllDrafts.labelId), + labelIdsToRemove = setOf(SystemLabelId.Sent.labelId, SystemLabelId.AllSent.labelId) + ) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/AttachmentStateEntitySample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/AttachmentStateEntitySample.kt new file mode 100644 index 0000000000..2f46d2e04a --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/AttachmentStateEntitySample.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.data.local.entity.AttachmentStateEntity +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import me.proton.core.domain.entity.UserId + +object AttachmentStateEntitySample { + + val LocalAttachmentState = build() + + val RemoteAttachmentState = build( + messageId = MessageIdSample.RemoteDraft, + state = AttachmentSyncState.Uploaded + ) + + + fun build( + userId: UserId = UserIdSample.Primary, + messageId: MessageId = MessageIdSample.RemoteDraft, + attachmentId: AttachmentId = AttachmentId("attachment_id"), + state: AttachmentSyncState = AttachmentSyncState.Local + ) = AttachmentStateEntity( + userId = userId, + messageId = messageId, + attachmentId = attachmentId, + state = state + ) + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/CreateDraftBodySample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/CreateDraftBodySample.kt new file mode 100644 index 0000000000..2417b94581 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/CreateDraftBodySample.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.composer.data.remote.resource.CreateDraftBody +import ch.protonmail.android.composer.data.remote.resource.DraftMessageResource +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import me.proton.core.crypto.common.pgp.Armored + +object CreateDraftBodySample { + + val NewDraftWithSubject = build() + + val NewDraftWithInvoiceAttachment = build( + message = DraftMessageResourceSample.NewDraftWithInvoiceAttachment, + attachmentKeyPackets = MessageWithBodySample.MessageWithInvoiceAttachment.messageBody.attachments + .filter { it.keyPackets != null } + .associate { it.attachmentId.id to it.keyPackets!! } + ) + + val NewDraftWithSubjectAndBody = build( + message = DraftMessageResourceSample.NewDraftWithSubjectAndBody + ) + + fun build( + message: DraftMessageResource = DraftMessageResourceSample.NewDraftWithSubject, + parentId: String? = null, + action: Int = -1, + attachmentKeyPackets: Map = emptyMap() + ) = CreateDraftBody( + message = message, + parentId = parentId, + action = action, + attachmentKeyPackets = attachmentKeyPackets + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftMessageResourceSample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftMessageResourceSample.kt new file mode 100644 index 0000000000..6ef84d2486 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftMessageResourceSample.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.composer.data.remote.resource.DraftMessageResource +import ch.protonmail.android.mailmessage.data.remote.resource.RecipientResource +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample + +object DraftMessageResourceSample { + + val NewDraftWithSubject = build(subject = "New draft, just typed the subject") + + val NewDraftWithSubjectAndBody = build( + subject = "New draft, just typed the subject", + body = "This is the body typed from the user, ENCRYPTED" + ) + + val NewDraftWithInvoiceAttachment = build( + subject = "Sending some documents", + body = "non-empty-body" + ) + + val RemoteDraft = build(subject = "Remote draft, known to the API") + + fun build(subject: String = "", body: String = "") = DraftMessageResource( + subject = subject, + unread = 0, + sender = RecipientResource(RecipientSample.John.address, RecipientSample.John.name), + toList = emptyList(), + ccList = emptyList(), + bccList = emptyList(), + externalId = null, + flags = 0L, + body = body, + mimeType = MimeType.PlainText.value + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftStateEntitySample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftStateEntitySample.kt new file mode 100644 index 0000000000..486438a8ae --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/DraftStateEntitySample.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.mailmessage.data.local.entity.DraftStateEntity +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import me.proton.core.domain.entity.UserId + +object DraftStateEntitySample { + + val NewDraftState = build() + + val RemoteDraft = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Synchronized + ) + + fun build( + userId: UserId = UserIdSample.Primary, + messageId: MessageId = MessageIdSample.EmptyDraft, + apiMessageId: MessageId? = null, + state: DraftSyncState = DraftSyncState.Local, + action: DraftAction = DraftAction.Compose, + sendingError: SendingError? = null + ) = DraftStateEntity( + userId = userId, + messageId = messageId, + apiMessageId = apiMessageId, + state = state, + action = action, + sendingError = sendingError + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/MessageWithBodyResourceSample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/MessageWithBodyResourceSample.kt new file mode 100644 index 0000000000..5d4e8a5b8c --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/MessageWithBodyResourceSample.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentCountsResource +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentResource +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentsInfoResource +import ch.protonmail.android.mailmessage.data.remote.resource.MessageWithBodyResource +import ch.protonmail.android.mailmessage.data.remote.resource.RecipientResource +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import me.proton.core.util.kotlin.toInt + +object MessageWithBodyResourceSample { + + val NewDraftWithSubject = MessageWithBodySample.NewDraftWithSubject.asResource() + + val NewDraftWithAttachments = MessageWithBodySample.MessageWithInvoiceAttachment.asResource() + + val NewDraftWithSubjectAndBody = MessageWithBodySample.NewDraftWithSubjectAndBody.asResource() + + val RemoteDraft = MessageWithBodySample.RemoteDraft.asResource() + + private fun MessageWithBody.asResource() = with(this) { + MessageWithBodyResource( + id = message.id, + order = message.order, + conversationId = message.conversationId.id, + subject = message.subject, + unread = message.unread.toInt(), + sender = with(message.sender) { RecipientResource(address, name) }, + toList = message.toList.map { RecipientResource(it.address, it.name) }, + ccList = message.ccList.map { RecipientResource(it.address, it.name) }, + bccList = message.bccList.map { RecipientResource(it.address, it.name) }, + time = message.time, + size = message.size, + expirationTime = message.expirationTime, + isReplied = message.isReplied.toInt(), + isRepliedAll = message.isRepliedAll.toInt(), + isForwarded = message.isForwarded.toInt(), + addressId = message.addressId.id, + labelIds = message.labelIds.map { it.id }, + externalId = message.externalId, + numAttachments = message.numAttachments, + flags = message.flags, + body = messageBody.body, + header = messageBody.header, + parsedHeaders = emptyMap(), + attachments = messageBody.attachments.asResource(), + mimeType = messageBody.mimeType.value, + spamScore = messageBody.spamScore, + replyTo = with(messageBody.replyTo) { RecipientResource(address, name) }, + replyTos = messageBody.replyTos.map { RecipientResource(it.address, it.name) }, + unsubscribeMethods = null, + attachmentsInfo = AttachmentsInfoResource( + AttachmentCountsResource(message.attachmentCount.calendar), + AttachmentCountsResource() + ) + ) + } + + private fun List.asResource() = this.map { + AttachmentResource( + id = it.attachmentId.id, + name = it.name, + size = it.size, + mimeType = it.mimeType, headers = emptyMap() + ) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/SendMessageSample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/SendMessageSample.kt new file mode 100644 index 0000000000..1b3bdb7b52 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/SendMessageSample.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.composer.data.usecase.GenerateSendMessagePackages +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.auth.domain.entity.Modulus +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.pgp.DataPacket +import me.proton.core.crypto.common.pgp.EncryptedPacket +import me.proton.core.crypto.common.pgp.KeyPacket +import me.proton.core.crypto.common.pgp.PacketType +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.key.domain.entity.key.PublicKey +import me.proton.core.mailsendpreferences.domain.model.SendPreferences +import me.proton.core.mailsettings.domain.entity.MimeType +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.network.domain.session.SessionId +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Duration.Companion.days + +@OptIn(ExperimentalEncodingApi::class) +object SendMessageSample { + + const val RecipientEmail = "recipient@email.com" + const val RecipientAliasEmail = "recipient+alias@email.com" + + const val CleartextBody = "Cleartext body of the message" + const val PlaintextMimeBodyEncryptedAndSigned = "Plaintext Mime Body, encrypted and signed" + val BodySessionKey = SessionKey("BodySessionKey".toByteArray()) + val MimeBodySessionKey = SessionKey("MimeBodySessionKey".toByteArray()) + val AttachmentSessionKey = SessionKey("AttachmentSessionKey".toByteArray()) + val EncryptedAttachmentSessionKey: KeyPacket = "EncryptedAttachmentSessionKey".toByteArray() + val EncryptedBodyDataPacket: DataPacket = "EncryptedBodyDataPacket".toByteArray() + val EncryptedMimeBodyDataPacket: DataPacket = "EncryptedMimeBodyDataPacket".toByteArray() + val RecipientBodyKeyPacket: KeyPacket = "RecipientBodyKeyPacket".toByteArray() + val SignedEncryptedMimeBody: Pair = Pair( + "SignedEncryptedMimeBody KeyPacket".toByteArray(), + "SignedEncryptedMimeBody DataPacket".toByteArray() + ) + val CleartextBodyKey = SendMessagePackage.Key( + Base64.encode(BodySessionKey.key), + GenerateSendMessagePackages.SessionKeyAlgorithm + ) + val CleartextMimeBodyKey = SendMessagePackage.Key( + Base64.encode(MimeBodySessionKey.key), + GenerateSendMessagePackages.SessionKeyAlgorithm + ) + val EncryptedPlaintextBodySplit = listOf( + EncryptedPacket( + packet = "EncryptedPlaintextBodySplit KeyPacket".toByteArray(), + type = PacketType.Key + ), + EncryptedPacket( + packet = "EncryptedPlaintextBodySplit DataPacket".toByteArray(), + type = PacketType.Data + ) + ) + val PlaintextMimeBodyEncryptedAndSignedSplit = listOf( + EncryptedPacket( + packet = "PlaintextMimeBodyEncryptedAndSigned KeyPacket".toByteArray(), + type = PacketType.Key + ), + EncryptedPacket( + packet = "PlaintextMimeBodyEncryptedAndSigned DataPacket".toByteArray(), + type = PacketType.Data + ) + ) + + val PublicKey: PublicKey = PublicKey("SendPreferences PublicKey", true, true, true, true) + + val PrivateKey: PrivateKey = PrivateKey( + "SendMessage Sample Private Key", + true, + true, + true, + true, + EncryptedByteArray("encrypted passphrase".encodeToByteArray()) + ) + + const val AttachmentId = "attachmentId" + val PasswordByteArray = "password".encodeToByteArray() + val MessagePassword = MessagePassword( + UserId("userId"), MessageId("messageId"), PasswordByteArray.decodeToString(), "hint" + ) + + val Modulus = Modulus("modulusId", "modulus") + val Auth = SendMessagePackage.Auth( + modulusId = Modulus.modulusId, + version = 4, + salt = "salt", + verifier = "verifier" + ) + + val TokenByteArray = "token".encodeToByteArray() + val Token = Base64.encode(TokenByteArray) + const val EncryptedToken = "encryptedToken" + + val SessionId = SessionId("sessionId") + + val MessageExpirationTime = MessageExpirationTime(UserId("userId"), MessageId("messageId"), 1.days) + + object SendPreferences { + + val ProtonMail = SendPreferences( + encrypt = true, + sign = true, + PackageType.ProtonMail, + mimeType = MimeType.PlainText, + PublicKey + ) + + val ProtonMailWithEmptyPublicKey = ProtonMail.copy(publicKey = null) + + val ProtonMailWithHtmlMime = ProtonMail.copy(mimeType = MimeType.Html) + + val PgpMime = ProtonMail.copy(pgpScheme = PackageType.PgpMime) + + val PgpInline = ProtonMail.copy(pgpScheme = PackageType.PgpInline) + + val PgpMimeEncryptFalse = ProtonMail.copy(pgpScheme = PackageType.PgpMime, encrypt = false) + + val ClearMime = ProtonMail.copy(pgpScheme = PackageType.ClearMime, encrypt = false, sign = true) + + val Cleartext = ProtonMail.copy(pgpScheme = PackageType.Cleartext, encrypt = false, sign = false) + } + + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/UpdateDraftBodySample.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/UpdateDraftBodySample.kt new file mode 100644 index 0000000000..3d49497eb6 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/sample/UpdateDraftBodySample.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.sample + +import ch.protonmail.android.composer.data.remote.resource.DraftMessageResource +import ch.protonmail.android.composer.data.remote.resource.UpdateDraftBody +import me.proton.core.crypto.common.pgp.Armored + +object UpdateDraftBodySample { + + val RemoteDraft = build(message = DraftMessageResourceSample.RemoteDraft) + + fun build( + message: DraftMessageResource = DraftMessageResourceSample.NewDraftWithSubject, + attachmentKeyPackets: Map = emptyMap() + ) = UpdateDraftBody( + message = message, + attachmentKeyPackets = attachmentKeyPackets + ) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachment.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachment.kt new file mode 100644 index 0000000000..54665fb582 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachment.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.Either +import arrow.core.raise.either +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.KeyPacket +import me.proton.core.crypto.common.pgp.SignaturePacket +import me.proton.core.key.domain.encryptFile +import me.proton.core.key.domain.encryptSessionKey +import me.proton.core.key.domain.generateNewSessionKey +import me.proton.core.key.domain.getUnarmored +import me.proton.core.key.domain.signFile +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject + +class EncryptAndSignAttachment @Inject constructor( + private val cryptoContext: CryptoContext +) { + + suspend operator fun invoke( + senderAddress: UserAddress, + attachment: File + ): Either = either { + senderAddress.useKeys(cryptoContext) { + val sessionKey = runCatching { + generateNewSessionKey() + }.fold( + onSuccess = { it }, + onFailure = { shift(AttachmentEncryptionError.FailedToGenerateSessionKey(it)) } + ) + + val keyPacket = runCatching { encryptSessionKey(sessionKey) }.fold( + onSuccess = { it }, + onFailure = { shift(AttachmentEncryptionError.FailedToEncryptSessionKey(it)) } + ) + + val encryptedAttachment = + runCatching { encryptFile(attachment.name, attachment.inputStream(), keyPacket) }.fold( + onSuccess = { it }, + onFailure = { shift(AttachmentEncryptionError.FailedToEncryptAttachment(it)) } + ) + + val signature = runCatching { getUnarmored(signFile(attachment)) }.fold( + onSuccess = { it }, + onFailure = { shift(AttachmentEncryptionError.FailedToSignAttachment(it)) } + ) + + EncryptedAttachmentResult( + keyPacket = keyPacket, + encryptedAttachment = encryptedAttachment, + signature = signature + ) + } + } +} + +data class EncryptedAttachmentResult( + val keyPacket: KeyPacket, + val encryptedAttachment: File, + val signature: SignaturePacket +) + +sealed class AttachmentEncryptionError(open val exception: Throwable) { + data class FailedToGenerateSessionKey(override val exception: Throwable) : + AttachmentEncryptionError(exception) + + data class FailedToEncryptSessionKey(override val exception: Throwable) : + AttachmentEncryptionError(exception) + + data class FailedToEncryptAttachment(override val exception: Throwable) : + AttachmentEncryptionError(exception) + + data class FailedToSignAttachment(override val exception: Throwable) : + AttachmentEncryptionError(exception) +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackages.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackages.kt new file mode 100644 index 0000000000..dce025b001 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackages.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.composer.data.extension.encryptAndSignText +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import me.proton.core.auth.domain.entity.Modulus +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.DataPacket +import me.proton.core.crypto.common.pgp.KeyPacket +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.crypto.common.pgp.dataPacket +import me.proton.core.crypto.common.pgp.keyPacket +import me.proton.core.crypto.common.pgp.split +import me.proton.core.key.domain.decryptMimeMessage +import me.proton.core.key.domain.decryptSessionKey +import me.proton.core.key.domain.decryptText +import me.proton.core.key.domain.encryptAndSignText +import me.proton.core.key.domain.entity.keyholder.KeyHolderContext +import me.proton.core.key.domain.useKeys +import me.proton.core.mailmessage.domain.entity.Email +import me.proton.core.mailsendpreferences.domain.model.SendPreferences +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.util.kotlin.filterNullValues +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +@Suppress("LongParameterList") +class GenerateMessagePackages @Inject constructor( + private val cryptoContext: CryptoContext, + private val generateSendMessagePackages: GenerateSendMessagePackages, + private val generateMimeBody: GenerateMimeBody +) { + + @Suppress("LongMethod") + suspend operator fun invoke( + senderAddress: UserAddress, + localDraft: MessageWithBody, + sendPreferences: Map, + attachmentFiles: Map, + messagePassword: MessagePassword?, + modulus: Modulus? + ): Either> = either { + + lateinit var decryptedPlaintextBodySessionKey: SessionKey + lateinit var encryptedPlaintextBodyDataPacket: DataPacket + + lateinit var decryptedMimeBodySessionKey: SessionKey + lateinit var encryptedMimeBodyDataPacket: DataPacket + + lateinit var signedAndEncryptedMimeBodyForRecipients: Map> + + lateinit var decryptedAttachmentSessionKeys: Map + + senderAddress.useKeys(cryptoContext) { + + // Decrypt session keys of all attachments for later creation of packages for plaintext recipients. + decryptedAttachmentSessionKeys = localDraft.messageBody.attachments.associate { attachment -> + attachment.keyPackets?.let { + val decryptedSessionKey = runCatching { decryptSessionKey(Base64.decode(it)) }.getOrElse { + raise(Error.GeneratingPackages("error decrypting session key for attachments", it)) + } + attachment.attachmentId.id to decryptedSessionKey + } ?: raise(Error.GeneratingPackages("attachment keypackets are null")) + } + + val encryptedBodyPgpMessage = localDraft.messageBody.body + + // Decrypt body's session key to send it for plaintext recipients. + val encryptedPlaintextBodySplit = encryptedBodyPgpMessage.split(cryptoContext.pgpCrypto) + decryptedPlaintextBodySessionKey = runCatching { + decryptSessionKey(encryptedPlaintextBodySplit.keyPacket()) + }.getOrElse { + raise(Error.GeneratingPackages("error decrypting session key for PlaintextBody", it)) + } + encryptedPlaintextBodyDataPacket = encryptedPlaintextBodySplit.dataPacket() + + // generate MIME version of the email + val decryptedBody = + if (localDraft.messageBody.mimeType == MimeType.MultipartMixed) { + runCatching { decryptMimeMessage(encryptedBodyPgpMessage).body.content }.getOrElse { + raise(Error.GeneratingPackages("error decrypting BodyPgpMessage as MimeMessage", it)) + } + } else { + runCatching { + decryptText(encryptedBodyPgpMessage) + }.getOrElse { + raise(Error.GeneratingPackages("error decrypting BodyPgpMessage as Text", it)) + } + } + + val mimeBody = generateMimeBody( + decryptedBody, + localDraft.messageBody.mimeType, + localDraft.messageBody.attachments, + attachmentFiles + ) + + // Encrypt and sign, then decrypt MIME body's session key to send it for plaintext recipients. + val encryptedMimeBodySplit = runCatching { + encryptAndSignText(mimeBody).split(cryptoContext.pgpCrypto) + }.getOrElse { + raise(Error.GeneratingPackages("error encrypting MimeBody", it)) + } + decryptedMimeBodySessionKey = runCatching { + decryptSessionKey(encryptedMimeBodySplit.keyPacket()) + }.getOrElse { + raise(Error.GeneratingPackages("error decryptong session key for encryptedMimeBody", it)) + } + encryptedMimeBodyDataPacket = encryptedMimeBodySplit.dataPacket() + + signedAndEncryptedMimeBodyForRecipients = sendPreferences.mapValues { entry -> + runCatching { + signAndEncryptMimeBody(entry, mimeBody, this, cryptoContext) + }.getOrElse { raise(Error.GeneratingPackages("error signing and encrypting MimeBody", it)) } + }.filterNullValues() + } + + val areAllAttachmentsSigned = localDraft.messageBody.attachments.all { it.signature != null } + + val packages = generateSendMessagePackages( + sendPreferences, + decryptedPlaintextBodySessionKey, + encryptedPlaintextBodyDataPacket, + decryptedMimeBodySessionKey, + encryptedMimeBodyDataPacket, + localDraft.messageBody.mimeType, + signedAndEncryptedMimeBodyForRecipients, + decryptedAttachmentSessionKeys, + areAllAttachmentsSigned, + messagePassword, + modulus + ).mapLeft { + Error.GeneratingPackages("error in generateSendMessagePackages: ${it.message}") + }.bind() + + val areAllSubpackagesGenerated = packages.sumOf { it.addresses.size } == sendPreferences.size + + return if (areAllSubpackagesGenerated) { + return packages.right() + } else Error.GeneratingPackages("not all subpackages were generated").left() + } + + private fun signAndEncryptMimeBody( + entry: Map.Entry, + plaintextMimeBody: String, + keyHolderContext: KeyHolderContext, + cryptoContext: CryptoContext + ): Pair? { + return with(entry.value) { + if (encrypt && pgpScheme != PackageType.ProtonMail) { + publicKey?.let { publicKey -> + keyHolderContext.encryptAndSignText(plaintextMimeBody, publicKey) + ?.split(cryptoContext.pgpCrypto) + ?.let { Pair(it.keyPacket(), it.dataPacket()) } + } + } else null + } + } + + sealed interface Error { + data class GeneratingPackages(val reason: String, val throwable: Throwable? = null) : Error + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBody.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBody.kt new file mode 100644 index 0000000000..05d1f41cc8 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBody.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import java.io.StringWriter +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MimeType +import com.github.mangstadt.vinnie.io.FoldedLineWriter +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random + +/** + * Correctly encodes and formats Message body in multipart/mixed content type. + */ +@OptIn(ExperimentalEncodingApi::class) +class GenerateMimeBody @Inject constructor() { + + @Suppress("ImplicitDefaultLocale") + operator fun invoke( + body: String, + bodyContentType: MimeType, + attachments: List, + attachmentFiles: Map + ): String { + + val bytes = ByteArray(16) + Random.nextBytes(bytes) + val boundaryHex = bytes.joinToString("") { + String.format("%02x", it) + } + + val boundary = "---------------------$boundaryHex" + + val stringWriter = StringWriter() + FoldedLineWriter(stringWriter).use { + it.write(body, true, Charsets.UTF_8) + } + val quotedPrintableBody = stringWriter.toString() + + val mimeAttachments = attachments.joinToString(separator = "\n") { attachment -> + attachmentFiles[attachment.attachmentId]?.let { attachmentFile -> + "${boundary}\n${generateMimeAttachment(attachment, attachmentFile)}" + } ?: "" + } + + return """ + |Content-Type: multipart/mixed; boundary=${boundary.substring(2)} + | + |$boundary + |Content-Transfer-Encoding: quoted-printable + |Content-Type: ${bodyContentType.value}; charset=utf-8 + | + |$quotedPrintableBody + |$mimeAttachments + |$boundary-- + """.trimMargin() + } + + private fun generateMimeAttachment(attachment: MessageAttachment, attachmentFile: File): String { + + val fileName = generateEncodedFilename(attachment.name) + + val stringWriter = StringWriter() + FoldedLineWriter(stringWriter).use { + it.write("Content-Transfer-Encoding: base64") + it.writeln() + it.write("Content-Type: ${attachment.mimeType}; filename=\"$fileName\"; name=\"$fileName\"") + it.writeln() + it.write("Content-Disposition: attachment; filename=\"$fileName\"; name=\"$fileName\"") + it.writeln() + it.writeln() + it.write(Base64.encode(attachmentFile.readBytes())) + } + + return stringWriter.toString() + } + + private fun generateEncodedFilename(filename: String): String { + // special way of encoding Base64 used in MIME: https://en.wikipedia.org/wiki/MIME#Encoded-Word + val base64Filename = Base64.encode(filename.toByteArray()) + return "=?UTF-8?B?$base64Filename?=" + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackages.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackages.kt new file mode 100644 index 0000000000..0422a6c7ba --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackages.kt @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.MimeType +import me.proton.core.auth.domain.entity.Modulus +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.DataPacket +import me.proton.core.crypto.common.pgp.KeyPacket +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.key.domain.encryptSessionKey +import me.proton.core.mailmessage.domain.entity.Email +import me.proton.core.mailsendpreferences.domain.model.SendPreferences +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.util.kotlin.takeIfNotEmpty +import me.proton.core.util.kotlin.toInt +import timber.log.Timber +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +@Suppress("LongParameterList", "MaxLineLength") +class GenerateSendMessagePackages @Inject constructor( + private val cryptoContext: CryptoContext +) { + + @Suppress("LongMethod") + suspend operator fun invoke( + sendPreferences: Map, + decryptedBodySessionKey: SessionKey, + encryptedBodyDataPacket: ByteArray, + decryptedMimeBodySessionKey: SessionKey, + encryptedMimeBodyDataPacket: ByteArray, + bodyContentType: MimeType, + signedEncryptedMimeBodies: Map>, + decryptedAttachmentSessionKeys: Map, + areAllAttachmentsSigned: Boolean, + messagePassword: MessagePassword?, + modulus: Modulus? + ): Either> = either { + + val sendPreferencesBySubpackageType = groupBySubpackageType( + sendPreferences, isEncryptOutside = messagePassword != null + ) + + val protonMailSendPreferences = sendPreferencesBySubpackageType.getOrDefault( + PackageType.ProtonMail, emptyList() + ) + + val protonMailPackage = generateProtonMail( + protonMailSendPreferences, + decryptedBodySessionKey, + decryptedAttachmentSessionKeys, + encryptedBodyDataPacket, + areAllAttachmentsSigned, + bodyContentType + ).bind() + + val cleartextSendPreferences = sendPreferencesBySubpackageType.getOrDefault( + PackageType.Cleartext, emptyList() + ) + + val cleartextPackage = generateCleartext( + cleartextSendPreferences, + decryptedBodySessionKey, + decryptedAttachmentSessionKeys, + encryptedBodyDataPacket, + bodyContentType + ).bind() + + val clearMimeSendPreferences = sendPreferencesBySubpackageType.getOrDefault( + PackageType.ClearMime, emptyList() + ) + + val clearMimePackage = + generateClearMime( + clearMimeSendPreferences, + encryptedMimeBodyDataPacket, + decryptedMimeBodySessionKey + ) + + val encryptedOutsideSendPreferences = sendPreferencesBySubpackageType.getOrDefault( + PackageType.EncryptedOutside, emptyList() + ) + + val encryptedOutsidePackage = + generateEncryptedOutside( + encryptedOutsideSendPreferences, + decryptedBodySessionKey, + decryptedAttachmentSessionKeys, + encryptedBodyDataPacket, + bodyContentType, + messagePassword, + modulus, + areAllAttachmentsSigned + ).bind() + + val pgpMimeSendPreferences = sendPreferencesBySubpackageType.getOrDefault( + PackageType.PgpMime, emptyList() + ) + + val pgpMimePackages = + generatePgpMime( + pgpMimeSendPreferences, + signedEncryptedMimeBodies, + areAllAttachmentsSigned + ) + + return ( + listOfNotNull( + protonMailPackage.takeIf { it.addresses.isNotEmpty() }, + cleartextPackage.takeIf { it.addresses.isNotEmpty() }, + clearMimePackage.takeIf { it.addresses.isNotEmpty() }, + encryptedOutsidePackage.takeIf { it.addresses.isNotEmpty() } + ) + pgpMimePackages + ).right() + } + + private fun groupBySubpackageType( + allSendPreferences: Map, + isEncryptOutside: Boolean + ): Map>> = allSendPreferences.entries.groupBy { + when (it.value.pgpScheme) { + PackageType.ProtonMail -> PackageType.ProtonMail + PackageType.Cleartext -> when { + isEncryptOutside -> PackageType.EncryptedOutside + it.value.sign -> PackageType.ClearMime + else -> PackageType.Cleartext + } + PackageType.PgpInline, + PackageType.PgpMime -> when { + isEncryptOutside -> PackageType.EncryptedOutside + it.value.encrypt -> PackageType.PgpMime + it.value.sign -> PackageType.ClearMime + else -> PackageType.Cleartext + } + PackageType.ClearMime -> if (isEncryptOutside) PackageType.EncryptedOutside else PackageType.ClearMime + else -> null + } + } + + private fun generateProtonMail( + sendPreferences: List>, + decryptedBodySessionKey: SessionKey, + decryptedAttachmentSessionKeys: Map, + encryptedBodyDataPacket: ByteArray, + areAllAttachmentsSigned: Boolean, + bodyContentType: MimeType + ): Either = either { + val addresses = sendPreferences.mapNotNull { (recipientEmail, sendPreference) -> + + val recipientPublicKey = sendPreference.publicKey + + if (recipientPublicKey == null) { + Timber.e("GenerateSendMessagePackages: publicKey for ${sendPreference.pgpScheme.name} was null") + return@mapNotNull null + } + + val recipientBodyKeyPacket = runCatching { + recipientPublicKey.encryptSessionKey( + cryptoContext, + decryptedBodySessionKey + ) + }.getOrElse { + raise(Error.ProtonMailAndCleartext("generateProtonMailAndCleartext: error encrypting SessionKey for recipientBodyKeyPacket")) + } + + val encryptedAttachmentKeyPackets = runCatching { + decryptedAttachmentSessionKeys.mapValues { + Base64.encode(recipientPublicKey.encryptSessionKey(cryptoContext, it.value)) + } + }.getOrElse { + raise(Error.ProtonMailAndCleartext("generateProtonMailAndCleartext: error encrypting SessionKey for encryptedAttachmentKeyPackets")) + } + + recipientEmail to SendMessagePackage.Address.Internal( + signature = areAllAttachmentsSigned.toInt(), + bodyKeyPacket = Base64.encode(recipientBodyKeyPacket), + attachmentKeyPackets = encryptedAttachmentKeyPackets + ) + }.toMap() + + val globalPackageType = addresses.map { it.value.type }.takeIfNotEmpty()?.reduce { a, b -> + a.or(b) // logical OR of package types + } ?: -1 + + return SendMessagePackage( + addresses = addresses, + mimeType = bodyContentType.value, + body = Base64.encode(encryptedBodyDataPacket), + type = globalPackageType + ).right() + } + + private fun generateCleartext( + sendPreferences: List>, + decryptedBodySessionKey: SessionKey, + decryptedAttachmentSessionKeys: Map, + encryptedBodyDataPacket: ByteArray, + bodyContentType: MimeType + ): Either = either { + + val addresses = sendPreferences.associate { (recipientEmail, _) -> + recipientEmail to SendMessagePackage.Address.ExternalCleartext(signature = false.toInt()) + } + + val bodyKey = SendMessagePackage.Key( + Base64.encode(decryptedBodySessionKey.key), + SessionKeyAlgorithm + ) + + val attachmentKeys = decryptedAttachmentSessionKeys.mapValues { + SendMessagePackage.Key(Base64.encode(it.value.key), SessionKeyAlgorithm) + } + + val globalPackageType = addresses.map { it.value.type }.takeIfNotEmpty()?.reduce { a, b -> + a.or(b) // logical OR of package types + } ?: -1 + + return SendMessagePackage( + addresses = addresses, + mimeType = bodyContentType.value, + body = Base64.encode(encryptedBodyDataPacket), + type = globalPackageType, + bodyKey = bodyKey, + attachmentKeys = attachmentKeys + ).right() + } + + private fun generateClearMime( + sendPreferences: List>, + encryptedMimeBodyDataPacket: ByteArray, + decryptedMimeBodySessionKey: SessionKey + ): SendMessagePackage { + + val addresses = sendPreferences.associate { (recipientEmail, _) -> + recipientEmail to SendMessagePackage.Address.ExternalSigned(signature = true.toInt()) + }.toMap() + + return SendMessagePackage( + addresses = addresses, + mimeType = MimeType.MultipartMixed.value, + body = Base64.encode(encryptedMimeBodyDataPacket), + type = PackageType.ClearMime.type, + bodyKey = SendMessagePackage.Key( + Base64.encode(decryptedMimeBodySessionKey.key), + SessionKeyAlgorithm + ) + ) + } + + private fun generatePgpMime( + sendPreferences: List>, + signedEncryptedMimeBodies: Map>, + areAllAttachmentsSigned: Boolean + ): List = sendPreferences.mapNotNull { (recipientEmail, _) -> + signedEncryptedMimeBodies[recipientEmail]?.let { + SendMessagePackage( + addresses = mapOf( + recipientEmail to SendMessagePackage.Address.ExternalEncrypted( + signature = areAllAttachmentsSigned.toInt(), + bodyKeyPacket = Base64.encode(it.first) + ) + ), + mimeType = MimeType.MultipartMixed.value, + body = Base64.encode(it.second), + type = PackageType.PgpMime.type + ) + } ?: null.also { + Timber.e("GenerateSendMessagePackages: signedEncryptedMimeBody was null") + } + } + + @Suppress("LongMethod") + private suspend fun generateEncryptedOutside( + sendPreferences: List>, + decryptedBodySessionKey: SessionKey, + decryptedAttachmentSessionKeys: Map, + encryptedBodyDataPacket: ByteArray, + bodyContentType: MimeType, + messagePassword: MessagePassword?, + modulus: Modulus?, + areAllAttachmentsSigned: Boolean + ): Either = either { + + val addresses = if (messagePassword != null && modulus != null) { + + sendPreferences.associate { (recipientEmail, _) -> + + val passwordByteArray = messagePassword.password.toByteArray() + + val bodyKeyPacket = runCatching { + Base64.encode( + cryptoContext.pgpCrypto.encryptSessionKeyWithPassword(decryptedBodySessionKey, passwordByteArray) + ) + }.getOrElse { + raise(Error.EncryptedOutside("generateEncryptedOutside: error encrypting SessionKey with password for bodyKeyPacket")) + } + + val attachmentKeyPackets = runCatching { + decryptedAttachmentSessionKeys.mapValues { + Base64.encode(cryptoContext.pgpCrypto.encryptSessionKeyWithPassword(it.value, passwordByteArray)) + } + }.getOrElse { + raise(Error.EncryptedOutside("generateEncryptedOutside: error encrypting SessionKey with password for attachmentKeyPackets")) + } + + val token = runCatching { + Base64.encode(cryptoContext.pgpCrypto.generateRandomBytes(size = 32)) + }.getOrElse { + raise(Error.EncryptedOutside("generateEncryptedOutside: error generating random bytes")) + } + + val encryptedToken = runCatching { + cryptoContext.pgpCrypto.encryptTextWithPassword(token, passwordByteArray) + }.getOrElse { + raise(Error.EncryptedOutside("generateEncryptedOutside: error encrypting token")) + } + + val passwordVerifier = runCatching { + cryptoContext.srpCrypto.calculatePasswordVerifier( + username = "", // required for legacy reasons, can be empty + password = passwordByteArray, + modulusId = modulus.modulusId, + modulus = modulus.modulus + ) + }.getOrElse { + raise(Error.EncryptedOutside("generateEncryptedOutside: error calculating password verifier")) + } + + val auth = SendMessagePackage.Auth( + modulusId = passwordVerifier.modulusId, + version = passwordVerifier.version, + salt = passwordVerifier.salt, + verifier = passwordVerifier.verifier + ) + + recipientEmail to SendMessagePackage.Address.EncryptedOutside( + bodyKeyPacket = bodyKeyPacket, + attachmentKeyPackets = attachmentKeyPackets, + token = token, + encToken = encryptedToken, + auth = auth, + passwordHint = messagePassword.passwordHint, + signature = areAllAttachmentsSigned.toInt() + ) + }.toMap() + } else emptyMap() + + val globalPackageType = addresses.map { it.value.type }.takeIfNotEmpty()?.reduce { a, b -> + a.or(b) // logical OR of package types + } ?: -1 + + return SendMessagePackage( + addresses = addresses, + mimeType = bodyContentType.value, + body = Base64.encode(encryptedBodyDataPacket), + type = globalPackageType + ).right() + } + + + companion object { + + const val SessionKeyAlgorithm = "aes256" + + } + + sealed class Error(open val message: String) { + data class ProtonMailAndCleartext(override val message: String) : Error(message) + data class EncryptedOutside(override val message: String) : Error(message) + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFiles.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFiles.kt new file mode 100644 index 0000000000..8569c9ebc0 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFiles.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.data.local.usecase.DecryptAttachmentByteArray +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class GetAttachmentFiles @Inject constructor( + private val attachmentRepository: AttachmentRepository, + private val decryptAttachmentByteArray: DecryptAttachmentByteArray, + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + attachmentIds: List + ): Either> = either { + val apiMessageId = draftStateRepository.observe(userId, messageId).first().onLeft { + Timber.e("No draft state found for $messageId when reading attachments from storage") + }.getOrNull()?.apiMessageId ?: raise(Error.DraftNotFound) + + attachmentIds.associateWith { attachmentId -> + attachmentRepository.readFileFromStorage(userId, apiMessageId, attachmentId).fold( + ifRight = { it }, + ifLeft = { + val encryptedAttachment = attachmentRepository.getAttachmentFromRemote( + userId, + apiMessageId, + attachmentId + ).mapLeft { Error.DownloadingAttachments } + .bind() + + decryptAttachmentByteArray(userId, apiMessageId, attachmentId, encryptedAttachment).fold( + ifRight = { + attachmentRepository.saveAttachmentToFile(userId, apiMessageId, attachmentId, it) + .mapLeft { Error.FailedToStoreFile } + .bind() + }, + ifLeft = { raise(Error.DownloadingAttachments) } + ) + } + ) + } + } + + sealed interface Error { + object DraftNotFound : Error + object FailedToStoreFile : Error + object DownloadingAttachments : Error + } + +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/SendMessage.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/SendMessage.kt new file mode 100644 index 0000000000..f829435eb5 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/SendMessage.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.composer.data.remote.MessageRemoteDataSource +import ch.protonmail.android.composer.data.remote.resource.SendMessageBody +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.isMessageAlreadySentSendingError +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailsettings.domain.usecase.ObserveMailSettings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.account.domain.repository.AccountRepository +import me.proton.core.auth.domain.repository.AuthRepository +import me.proton.core.domain.entity.UserId +import me.proton.core.mailmessage.domain.entity.Email +import me.proton.core.mailsendpreferences.domain.model.SendPreferences +import me.proton.core.mailsendpreferences.domain.usecase.ObtainSendPreferences +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.util.kotlin.filterNullValues +import me.proton.core.util.kotlin.filterValues +import me.proton.core.util.kotlin.toInt +import timber.log.Timber +import javax.inject.Inject + +class SendMessage @Inject constructor( + private val accountRepository: AccountRepository, + private val authRepository: AuthRepository, + private val messageRemoteDataSource: MessageRemoteDataSource, + private val resolveUserAddress: ResolveUserAddress, + private val generateMessagePackages: GenerateMessagePackages, + private val findLocalDraft: FindLocalDraft, + private val obtainSendPreferences: ObtainSendPreferences, + private val observeMailSettings: ObserveMailSettings, + private val getAttachmentFiles: GetAttachmentFiles, + private val observeMessagePassword: ObserveMessagePassword, + private val observeMessageExpirationTime: ObserveMessageExpirationTime +) { + + /** + * Because we ignore versioning conflicts between different clients, we assume that by the time this is called, + * local draft has been correctly uploaded to backend and we will get the final version from DB here. Draft + * should also be locked for editing by now. + */ + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either = either { + + val localDraft = findLocalDraft(userId, messageId) ?: raise(Error.DraftNotFound) + + val senderAddress = resolveUserAddress(userId, localDraft.message.addressId) + .mapLeft { Error.SenderAddressNotFound } + .bind() + + val autoSaveContacts = observeMailSettings(userId).firstOrNull()?.autoSaveContacts ?: false + + val recipients = localDraft.message.toList + localDraft.message.ccList + localDraft.message.bccList + + val sendPreferences = getSendPreferences(userId, recipients.map { it.address }).bind() + + val attachmentFiles = if (sendPreferences.containsMimeSchemePreferences()) { + val attachmentIds = localDraft.messageBody.attachments.map { it.attachmentId } + getAttachmentFiles(userId, messageId, attachmentIds).mapLeft { Error.DownloadingAttachments }.bind() + } else { + emptyMap() + } + + val messagePassword = observeMessagePassword(userId, messageId).first() + val modulus = if (messagePassword != null) { + val sessionId = accountRepository.getSessionIdOrNull(userId) + authRepository.randomModulus(sessionId) + } else null + + val messagePackages = generateMessagePackages( + senderAddress = senderAddress, + localDraft = localDraft, + sendPreferences = sendPreferences, + attachmentFiles = attachmentFiles, + messagePassword = messagePassword, + modulus = modulus + ) + .mapLeft { + Timber.tag("SendMessage").e("Error generating packages: $it") + Error.GeneratingPackages + } + .bind() + + val expiresInSeconds = observeMessageExpirationTime(userId, messageId).first()?.expiresIn?.inWholeSeconds ?: 0 + + val response = messageRemoteDataSource.send( + userId, + localDraft.message.messageId.id, + SendMessageBody( + expiresIn = expiresInSeconds, + autoSaveContacts = autoSaveContacts.toInt(), + packages = messagePackages + ) + ).mapLeft { Error.SendingToApi(it) } + + response.onLeft { + Timber.tag("SendMessage").e("API error sending - error: %s - messageId: %s", it, messageId) + }.onRight { + Timber.tag("SendMessage").d("Success sending message ID: $messageId") + }.bind() + } + + private suspend fun getSendPreferences( + userId: UserId, + emails: List + ): Either> { + + val sendPreferencesResults = runCatching { + obtainSendPreferences(userId, emails) + }.getOrElse { + Timber.tag("SendMessage").e( + "Unexpected exception ${it.message} while obtaining send preferences for $userId" + ) + return Error.SendPreferences(emptyMap()).left() + } + + val sendPreferencesSuccesses = sendPreferencesResults + .filterValues() + + val sendPreferencesErrors = sendPreferencesResults.mapValues { + if (it.value is ObtainSendPreferences.Result.Error) { + it.value as ObtainSendPreferences.Result.Error + } else null + }.filterNullValues() + + if (sendPreferencesErrors.isNotEmpty()) return Error.SendPreferences(sendPreferencesErrors).left() + + val sendPreferences = sendPreferencesSuccesses + .mapValues { it.value.sendPreferences } + + val uniqueEmails = emails.distinctBy { it.lowercase() } + // we failed getting send preferences for all recipients + return if (sendPreferences.size != uniqueEmails.size) { + Error.SendPreferences(emptyMap()).left() + } else sendPreferences.right() + } + + private fun Map.containsMimeSchemePreferences() = values.any { + it.encrypt && it.pgpScheme != PackageType.ProtonMail || + it.encrypt.not() && it.sign + } + + sealed interface Error { + + object DraftNotFound : Error + + object SenderAddressNotFound : Error + + data class SendPreferences( + /** + * Detail mapping of what went wrong with SendPreferences for recipient emails. + */ + val errors: Map + ) : Error + + object GeneratingPackages : Error + + data class SendingToApi(val remoteDataError: DataError.Remote) : Error + + object DownloadingAttachments : Error + + fun toSendingError(): SendingError { + return when (this) { + is SendPreferences -> { + SendingError.SendPreferences( + this.errors.mapValues { + when (it.value) { + ObtainSendPreferences.Result.Error.AddressDisabled -> { + SendingError.SendPreferencesError.AddressDisabled + } + ObtainSendPreferences.Result.Error.GettingContactPreferences -> { + SendingError.SendPreferencesError.GettingContactPreferences + } + ObtainSendPreferences.Result.Error.NoCorrectlySignedTrustedKeys -> { + SendingError.SendPreferencesError.NoCorrectlySignedTrustedKeys + } + ObtainSendPreferences.Result.Error.PublicKeysInvalid -> { + SendingError.SendPreferencesError.PublicKeysInvalid + } + ObtainSendPreferences.Result.Error.TrustedKeysInvalid -> { + SendingError.SendPreferencesError.TrustedKeysInvalid + } + } + } + ) + } + + is SendingToApi -> { + if (this.remoteDataError.isMessageAlreadySentSendingError()) { + SendingError.MessageAlreadySent + } else { + SendingError.Other + } + } + + else -> SendingError.Other + } + } + } +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachments.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachments.kt new file mode 100644 index 0000000000..c83881a165 --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachments.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSource +import ch.protonmail.android.composer.data.remote.UploadAttachmentModel +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class UploadAttachments @Inject constructor( + private val attachmentRepository: AttachmentRepository, + private val attachmentStateRepository: AttachmentStateRepository, + private val attachmentRemoteDataSource: AttachmentRemoteDataSource, + private val findLocalDraft: FindLocalDraft, + private val encryptAndSignAttachment: EncryptAndSignAttachment, + private val resolveUserAddress: ResolveUserAddress, + private val transactor: Transactor +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either = either { + val localDraft = findLocalDraft(userId, messageId)?.message ?: shift(AttachmentUploadError.DraftNotFound) + + val attachments = attachmentStateRepository.getAllAttachmentStatesForMessage(userId, localDraft.messageId) + .filter { it.state == AttachmentSyncState.Local } + + if (attachments.isEmpty()) return@either + + val senderAddress = resolveUserAddress(userId, localDraft.addressId) + .mapLeft { AttachmentUploadError.SenderAddressNotFound } + .bind() + + attachments.forEach { attachmentState -> + val attachment = attachmentRepository + .readFileFromStorage(userId, localDraft.messageId, attachmentState.attachmentId) + .mapLeft { AttachmentUploadError.AttachmentFileNotFound } + .bind() + + val attachmentInfo = attachmentRepository + .getAttachmentInfo(userId, localDraft.messageId, attachmentState.attachmentId) + .mapLeft { AttachmentUploadError.AttachmentInfoNotFound } + .bind() + + val encryptedAttachment = encryptAndSignAttachment(senderAddress, attachment) + .mapLeft { AttachmentUploadError.FailedToEncryptAttachment } + .bind() + + val uploadAttachment = UploadAttachmentModel( + messageId = localDraft.messageId, + fileName = attachmentInfo.name, + mimeType = attachmentInfo.mimeType, + keyPacket = encryptedAttachment.keyPacket, + attachment = encryptedAttachment.encryptedAttachment, + signature = encryptedAttachment.signature + ) + + val response = attachmentRemoteDataSource.uploadAttachment(userId, uploadAttachment) + .mapLeft { + Timber.e("Failed to upload attachment: $it") + AttachmentUploadError.UploadFailed(it) + } + .bind() + + transactor.performTransaction { + attachmentRepository.updateMessageAttachment( + userId = userId, + messageId = localDraft.messageId, + localAttachmentId = attachmentState.attachmentId, + attachment = response.attachment.toMessageAttachment() + ).mapLeft { AttachmentUploadError.FailedToStoreAttachmentInfo }.bind() + attachmentStateRepository.setAttachmentToUploadState( + userId = userId, + messageId = localDraft.messageId, + attachmentId = AttachmentId(response.attachment.id) + ).mapLeft { AttachmentUploadError.FailedToUpdateAttachmentId }.bind() + } + } + } +} + +sealed interface AttachmentUploadError { + object DraftNotFound : AttachmentUploadError + object SenderAddressNotFound : AttachmentUploadError + object AttachmentFileNotFound : AttachmentUploadError + object AttachmentInfoNotFound : AttachmentUploadError + object FailedToEncryptAttachment : AttachmentUploadError + data class UploadFailed(val remoteDataError: DataError.Remote) : AttachmentUploadError + object FailedToUpdateAttachmentId : AttachmentUploadError + object FailedToStoreAttachmentInfo : AttachmentUploadError +} diff --git a/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraft.kt b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraft.kt new file mode 100644 index 0000000000..27e12b5e4d --- /dev/null +++ b/mail-composer/data/src/main/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraft.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.composer.data.remote.DraftRemoteDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.usecase.CreateOrUpdateParentAttachmentStates +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailcomposer.domain.usecase.IsDraftKnownToApi +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +internal class UploadDraft @Inject constructor( + private val transactor: Transactor, + private val messageRepository: MessageRepository, + private val findLocalDraft: FindLocalDraft, + private val draftStateRepository: DraftStateRepository, + private val draftRemoteDataSource: DraftRemoteDataSource, + private val isDraftKnownToApi: IsDraftKnownToApi, + private val attachmentRepository: AttachmentRepository, + private val updateParentAttachments: CreateOrUpdateParentAttachmentStates, + private val draftUploadTracker: DraftUploadTracker +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either = either { + Timber.d("Draft: Requested draft upload for $messageId") + + val message = findLocalDraft(userId, messageId) + if (message == null) { + Timber.d("Sync draft failure $messageId: No message found") + shift(DataError.Local.NoDataCached) + // Return for the compiler's sake (message optionality). shift is causing a left to be returned just above + return@either + } + Timber.d("Draft: Uploading draft for ${message.message.messageId}") + + val draftState = draftStateRepository.observe(userId, messageId).first().onLeft { + Timber.w("Sync draft failure $messageId: No draft state found") + }.bind() + + if (isDraftKnownToApi(draftState)) { + handleUpdateDraft(userId, message, messageId).bind() + } else { + handleCreateDraft(userId, message, draftState, messageId).bind() + + } + } + + private suspend fun handleCreateDraft( + userId: UserId, + message: MessageWithBody, + draftState: DraftState, + messageId: MessageId + ) = draftRemoteDataSource.create(userId, message, draftState.action).onRight { + transactor.performTransaction { + messageRepository.updateDraftRemoteIds(userId, messageId, it.message.messageId, it.message.conversationId) + draftStateRepository.updateApiMessageIdAndSetSyncedState(userId, messageId, it.message.messageId) + updateAttachmentsData(message, it) + } + }.onLeft { + if (it.shouldLogToSentry()) { + Timber.w("Sync draft failure $messageId: Create API call error $it") + } + Timber.d("Sync draft error $messageId: Create API call error $it") + } + + private suspend fun handleUpdateDraft( + userId: UserId, + message: MessageWithBody, + messageId: MessageId + ) = draftRemoteDataSource.update(userId, message).onRight { + draftStateRepository.updateApiMessageIdAndSetSyncedState( + userId, it.message.messageId, it.message.messageId + ) + draftUploadTracker.notifyUploadedDraft(messageId, message) + }.onLeft { + Timber.w("Sync draft failure $messageId: Update API call error $it") + } + + /* + * Matches local attachments with remote ones by keyPackets, name and size and updates their ID. + * This is needed to refresh the data of "parent attachments", which to support offline mode are copied for + * this message and only receive their "real" id when contacting API to create the draft. + */ + private suspend fun updateAttachmentsData(localMessage: MessageWithBody, apiMessage: MessageWithBody) { + val localAttachments = localMessage.messageBody.attachments + val remoteAttachments = apiMessage.messageBody.attachments + + remoteAttachments.forEach { attachment -> + localAttachments.find { + it.keyPackets == attachment.keyPackets && + it.name == attachment.name && it.mimeType == attachment.mimeType + }?.let { localAttachment -> + attachmentRepository.updateMessageAttachment( + apiMessage.message.userId, + apiMessage.message.messageId, + localAttachment.attachmentId, + attachment + ) + } ?: Timber.w("Attachment not found in local message: $attachment") + } + if (remoteAttachments.isNotEmpty()) { + updateParentAttachments( + userId = apiMessage.message.userId, + messageId = apiMessage.message.messageId, + attachmentIds = remoteAttachments.map { it.attachmentId } + ) + } + } + + private fun DataError.Remote.shouldLogToSentry() = this != DataError.Remote.CreateDraftRequestNotPerformed + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..8c325115c8 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/AttachmentStateLocalDataSourceImplTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.sample.AttachmentStateEntitySample +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.AttachmentStateSample +import ch.protonmail.android.mailmessage.data.local.dao.AttachmentStateDao +import ch.protonmail.android.mailmessage.data.local.entity.AttachmentStateEntity +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachmentStateLocalDataSourceImplTest { + + private val attachmentStateDao = mockk(relaxUnitFun = true) + private val draftStateDatabase = mockk { + every { attachmentStateDao() } returns attachmentStateDao + } + + private val localDataSource = AttachmentStateLocalDataSourceImpl(draftStateDatabase) + + @Test + fun `get attachment state returns it when existing`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.EmptyDraft + val attachmentId = AttachmentId("attachment_id") + val expected = AttachmentStateSample.LocalAttachmentState + val attachmentStateEntity = AttachmentStateEntitySample.LocalAttachmentState + expectAttachmentStateDaoSuccess(userId, messageId, attachmentId, attachmentStateEntity) + + // When + val actual = localDataSource.getAttachmentState(userId, messageId, attachmentId) + + // Then + assertEquals(expected.right(), actual) + } + + @Test + fun `get attachment state returns no data when not existing`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.EmptyDraft + val attachmentId = AttachmentId("attachment_id") + val expected = DataError.Local.NoDataCached + expectAttachmentStateDaoReturnsNull(userId, messageId, attachmentId) + + // When + val actual = localDataSource.getAttachmentState(userId, messageId, attachmentId) + + // Then + assertEquals(expected.left(), actual) + } + + @Test + fun `save attachment state returns Unit when succeeding`() = runTest { + // Given + val attachmentState = AttachmentStateSample.LocalAttachmentState + val attachmentStateEntity = AttachmentStateEntitySample.LocalAttachmentState + expectAttachmentStateDaoUpsertSuccess(attachmentStateEntity) + + // When + val actual = localDataSource.createOrUpdate(attachmentState) + + // Then + assertEquals(Unit.right(), actual) + coVerify { attachmentStateDao.insertOrUpdate(attachmentStateEntity) } + } + + @Test + fun `save attachment states returns Unit when succeeding`() = runTest { + // Given + val attachmentStates = listOf( + AttachmentStateSample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_1")), + AttachmentStateSample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_2")), + AttachmentStateSample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_3")) + ) + val attachmentStateEntities = listOf( + AttachmentStateEntitySample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_1")), + AttachmentStateEntitySample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_2")), + AttachmentStateEntitySample.LocalAttachmentState.copy(attachmentId = AttachmentId("attachment_id_3")) + ) + + // When + val actual = localDataSource.createOrUpdate(attachmentStates) + + // Then + assertEquals(Unit.right(), actual) + coVerify { attachmentStateDao.insertOrUpdate(*attachmentStateEntities.toTypedArray()) } + } + + @Test + fun `delete attachment state calls dao`() = runTest { + val attachmentState = AttachmentStateSample.LocalAttachmentState + val attachmentStateEntity = AttachmentStateEntitySample.LocalAttachmentState + expectedAttachmentStateDaoDeleteSuccess(attachmentStateEntity) + + localDataSource.delete(attachmentState) + + coVerify { attachmentStateDao.delete(attachmentStateEntity) } + } + + @Test + fun `get all states of all attachments connected to user and message`() = runTest { + val userId = UserIdSample.Primary + val messageId = MessageIdSample.EmptyDraft + val attachmentStateEntity = listOf(AttachmentStateEntitySample.LocalAttachmentState) + val expected = listOf(AttachmentStateSample.LocalAttachmentState) + expectGetAllAttachmentStatesForMessageSuccessful(userId, messageId, attachmentStateEntity) + + val actual = localDataSource.getAllAttachmentStatesForMessage(userId, messageId) + + assertEquals(expected, actual) + } + + @Test + fun `get all states of attachments for user and message returns empty list when no states are stored`() = runTest { + val userId = UserIdSample.Primary + val messageId = MessageIdSample.EmptyDraft + val expectedStateEntities = listOf() + expectGetAllAttachmentStatesForMessageSuccessful(userId, messageId, expectedStateEntities) + + val actual = localDataSource.getAllAttachmentStatesForMessage(userId, messageId) + + assertEquals(emptyList(), actual) + } + + private fun expectAttachmentStateDaoSuccess( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId, + attachmentStateEntity: AttachmentStateEntity + ) { + coEvery { + attachmentStateDao.getAttachmentState(userId, messageId, attachmentId) + } returns attachmentStateEntity + } + + private fun expectAttachmentStateDaoReturnsNull( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ) { + coEvery { + attachmentStateDao.getAttachmentState(userId, messageId, attachmentId) + } returns null + } + + private fun expectAttachmentStateDaoUpsertSuccess(attachmentStateEntity: AttachmentStateEntity) { + coEvery { attachmentStateDao.insertOrUpdate(attachmentStateEntity) } returns Unit + } + + private fun expectedAttachmentStateDaoDeleteSuccess(attachmentStateEntity: AttachmentStateEntity) { + coEvery { attachmentStateDao.delete(attachmentStateEntity) } returns Unit + } + + private fun expectGetAllAttachmentStatesForMessageSuccessful( + userId: UserId, + messageId: MessageId, + attachmentStateEntities: List + ) { + coEvery { + attachmentStateDao.getAllAttachmentStatesForMessage(userId, messageId) + } returns attachmentStateEntities + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..3278e70c2d --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionLocalDataSourceImplTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class ContactsPermissionLocalDataSourceImplTest { + private val preferences = mockk() + private val contactsPermissionDataStoreSpy = spyk> { + every { data } returns flowOf(preferences) + } + private val dataStoreProviderMock = mockk { + every { contactsPermissionsStore } returns contactsPermissionDataStoreSpy + } + + private val dataSource = ContactsPermissionLocalDataSourceImpl(dataStoreProviderMock) + + @Test + fun `should return data when present`() = runTest { + // Given + val expectedState = false + every { preferences[booleanPreferencesKey(SHOULD_STOP_SHOWING_PERMISSION_DIALOG)] } returns expectedState + + // When + val actual = dataSource.observePermissionDenied().first() + + // Then + assertEquals(expectedState.right(), actual) + } + + @Test + fun `should return an error when data is not present`() = runTest { + every { preferences[booleanPreferencesKey(SHOULD_STOP_SHOWING_PERMISSION_DIALOG)] } returns null + + // When + val actual = dataSource.observePermissionDenied().first() + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + + @Test + fun `should save the denied state when invoked`() = runTest { + val transformSlot = slot Preferences>() + val mutablePreferences = mockk() + + every { preferences.toMutablePreferences() } returns mutablePreferences + every { mutablePreferences[any>()] = any() } returns Unit + + // When + dataSource.trackPermissionDeniedEvent() + + // Then + coVerify { + contactsPermissionDataStoreSpy.updateData(capture(transformSlot)) + } + + transformSlot.captured.invoke(preferences) + + verify { + mutablePreferences[booleanPreferencesKey(SHOULD_STOP_SHOWING_PERMISSION_DIALOG)] = true + } + } + + private companion object { + const val SHOULD_STOP_SHOWING_PERMISSION_DIALOG = "HasDeniedContactsPermission" + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionRepositoryImplTest.kt new file mode 100644 index 0000000000..7a9260b6d9 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/ContactsPermissionRepositoryImplTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import app.cash.turbine.test +import arrow.core.Either +import arrow.core.right +import ch.protonmail.android.composer.data.repository.ContactsPermissionRepositoryImpl +import ch.protonmail.android.mailcommon.domain.model.DataError +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class ContactsPermissionRepositoryImplTest { + + private val dataSource = mockk() + + @Test + fun `should observe the permission denied state from the data source`() = runTest { + // Given + val sharedFlow = MutableSharedFlow>() + val expectedResult = true.right() + every { dataSource.observePermissionDenied() } returns sharedFlow + val repo = ContactsPermissionRepositoryImpl(dataSource) + + // When + repo.observePermissionDenied().test { + sharedFlow.emit(expectedResult) + + assertEquals(expectedResult, awaitItem()) + } + + // Then + verify(exactly = 1) { dataSource.observePermissionDenied() } + confirmVerified(dataSource) + } + + @Test + fun `should track the denial state through the data source`() = runTest { + // Given + coEvery { dataSource.trackPermissionDeniedEvent() } just runs + val repo = ContactsPermissionRepositoryImpl(dataSource) + + // When + repo.trackPermissionDenied() + // Then + coVerify(exactly = 1) { dataSource.trackPermissionDeniedEvent() } + confirmVerified(dataSource) + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..56629e29c0 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/DraftStateLocalDataSourceImplTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.dao.DraftStateDao +import ch.protonmail.android.mailmessage.data.local.entity.DraftStateEntity +import ch.protonmail.android.composer.data.sample.DraftStateEntitySample +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals + +class DraftStateLocalDataSourceImplTest { + + private val draftStateDao = mockk(relaxUnitFun = true) + private val draftStateDatabase = mockk { + every { this@mockk.draftStateDao() } returns draftStateDao + } + + private val localDataSource = DraftStateLocalDataSourceImpl(draftStateDatabase) + + @Test + fun `observe draft state returns it when existing`() = runTest { + val userId = UserIdSample.Primary + val draftId = MessageIdSample.EmptyDraft + val expected = DraftStateSample.NewDraftState + val draftStateEntity = DraftStateEntitySample.NewDraftState + expectDraftStateDaoSuccess(userId, draftId, draftStateEntity) + + val actual = localDataSource.observe(userId, draftId).first() + + assertEquals(expected.right(), actual) + } + + @Test + fun `observe draft state returns no data cached error when not existing`() = runTest { + val userId = UserIdSample.Primary + val draftId = MessageIdSample.EmptyDraft + val expectedError = DataError.Local.NoDataCached + expectDraftStateDaoReturnsNull(userId, draftId) + + val actual = localDataSource.observe(userId, draftId).first() + + assertEquals(expectedError.left(), actual) + } + + @Test + fun `save draft state returns Unit when succeeding`() = runTest { + val draftState = DraftStateSample.RemoteDraftState + val draftStateEntity = DraftStateEntitySample.RemoteDraft + expectDraftStateDaoUpsertSuccess(draftStateEntity) + + val actual = localDataSource.save(draftState) + + assertEquals(Unit.right(), actual) + coVerify { draftStateDao.insertOrUpdate(draftStateEntity) } + } + + private fun expectDraftStateDaoUpsertSuccess(draftStateEntity: DraftStateEntity) { + coEvery { draftStateDao.insertOrUpdate(draftStateEntity) } returns Unit + } + + private fun expectDraftStateDaoSuccess( + userId: UserId, + draftId: MessageId, + expected: DraftStateEntity + ) { + every { draftStateDao.observeDraftState(userId, draftId) } returns flowOf(expected) + } + + private fun expectDraftStateDaoReturnsNull(userId: UserId, draftId: MessageId) { + every { draftStateDao.observeDraftState(userId, draftId) } returns flowOf(null) + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..01f6fbfa83 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessageExpirationTimeLocalDataSourceImplTest.kt @@ -0,0 +1,92 @@ +package ch.protonmail.android.composer.data.local + +import java.io.IOException +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.dao.MessageExpirationTimeDao +import ch.protonmail.android.composer.data.local.entity.toEntity +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days + +class MessageExpirationTimeLocalDataSourceImplTest { + + val userId = UserIdTestData.userId + val messageId = MessageIdSample.NewDraftWithSubjectAndBody + + private val messageExpirationTimeDao = mockk() + private val database = mockk { + every { messageExpirationTimeDao() } returns messageExpirationTimeDao + } + + private val messageExpirationTimeLocalDataSource = MessageExpirationTimeLocalDataSourceImpl(database) + + @Test + fun `should return unit when saving message expiration time was successful`() = runTest { + // Given + val expiresIn = 1.days + val messageExpirationTime = MessageExpirationTime(userId, messageId, expiresIn) + coEvery { messageExpirationTimeDao.insertOrUpdate(messageExpirationTime.toEntity()) } just runs + + // When + val actual = messageExpirationTimeLocalDataSource.save(messageExpirationTime) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when saving message expiration time throws an exception`() = runTest { + // Given + val expiresIn = 1.days + val messageExpirationTime = MessageExpirationTime(userId, messageId, expiresIn) + coEvery { messageExpirationTimeDao.insertOrUpdate(messageExpirationTime.toEntity()) } throws IOException() + + // When + val actual = messageExpirationTimeLocalDataSource.save(messageExpirationTime) + + // Then + assertEquals(DataError.Local.DbWriteFailed.left(), actual) + } + + @Test + fun `should return message expiration time when observing and expiration time exists`() = runTest { + // Given + val expiresIn = 1.days + val messageExpirationTime = MessageExpirationTime(userId, messageId, expiresIn) + coEvery { messageExpirationTimeDao.observe(userId, messageId) } returns flowOf(messageExpirationTime.toEntity()) + + // When + messageExpirationTimeLocalDataSource.observe(userId, messageId).test { + // Then + assertEquals(messageExpirationTime, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return null when observing and expiration time does not exist`() = runTest { + // Given + coEvery { messageExpirationTimeDao.observe(userId, messageId) } returns flowOf(null) + + // When + messageExpirationTimeLocalDataSource.observe(userId, messageId).test { + // Then + assertNull(awaitItem()) + awaitComplete() + } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..62b061002d --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/local/MessagePasswordLocalDataSourceImplTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.local + +import java.io.IOException +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.dao.MessagePasswordDao +import ch.protonmail.android.composer.data.local.entity.toEntity +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MessagePasswordLocalDataSourceImplTest { + + val userId = UserIdTestData.userId + val messageId = MessageIdSample.NewDraftWithSubjectAndBody + + private val messagePasswordDao = mockk() + private val database = mockk { + every { messagePasswordDao() } returns messagePasswordDao + } + + private val messagePasswordLocalDataSource = MessagePasswordLocalDataSourceImpl(database) + + @Test + fun `should return unit when saving message password was successful`() = runTest { + // Given + val password = "password" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, password, hint) + coEvery { messagePasswordDao.insertOrUpdate(messagePassword.toEntity()) } just runs + + // When + val actual = messagePasswordLocalDataSource.save(messagePassword) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when saving message password throws an exception`() = runTest { + // Given + val password = "password" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, password, hint) + coEvery { messagePasswordDao.insertOrUpdate(messagePassword.toEntity()) } throws IOException() + + // When + val actual = messagePasswordLocalDataSource.save(messagePassword) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + @Test + fun `should return unit when updating message password was successful`() = runTest { + // Given + val password = "password" + val hint = "hint" + coEvery { messagePasswordDao.updatePasswordAndHint(userId, messageId, password, hint) } just runs + + // When + val actual = messagePasswordLocalDataSource.update(userId, messageId, password, hint) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when updating message password throws an exception`() = runTest { + // Given + val password = "password" + val hint = "hint" + coEvery { messagePasswordDao.updatePasswordAndHint(userId, messageId, password, hint) } throws IOException() + + // When + val actual = messagePasswordLocalDataSource.update(userId, messageId, password, hint) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + @Test + fun `should return message password when observing and password exists`() = runTest { + // Given + val password = "password" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, password, hint) + coEvery { messagePasswordDao.observe(userId, messageId) } returns flowOf(messagePassword.toEntity()) + + // When + messagePasswordLocalDataSource.observe(userId, messageId).test { + // Then + assertEquals(messagePassword, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return null when observing and password does not exist`() = runTest { + // Given + coEvery { messagePasswordDao.observe(userId, messageId) } returns flowOf(null) + + // When + messagePasswordLocalDataSource.observe(userId, messageId).test { + // Then + assertNull(awaitItem()) + awaitComplete() + } + } + + @Test + fun `should call delete method from dao when deleting message password`() = runTest { + // Given + coEvery { messagePasswordDao.delete(userId, messageId) } just runs + + // When + messagePasswordLocalDataSource.delete(userId, messageId) + + // Then + coVerify { messagePasswordDao.delete(userId, messageId) } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImplTest.kt new file mode 100644 index 0000000000..aa8b0395b5 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/AttachmentRemoteDataSourceImplTest.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import java.net.UnknownHostException +import java.util.UUID +import androidx.work.WorkManager +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.remote.response.UploadAttachmentResponse +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentResource +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.pgp.EncryptedFile +import me.proton.core.network.data.ApiManagerFactory +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.session.SessionId +import me.proton.core.network.domain.session.SessionProvider +import me.proton.core.test.android.api.TestApiManager +import me.proton.core.util.kotlin.DefaultDispatcherProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachmentRemoteDataSourceImplTest { + + private val userId = UserIdSample.Primary + private val attachmentId = AttachmentId("attachmentId") + private val sessionProvider = mockk { + coEvery { getSessionId(userId) } returns SessionId("test- session-id") + } + + private val attachmentApi = mockk() + private val apiManagerFactory = mockk { + every { create(any(), AttachmentApi::class) } returns TestApiManager(attachmentApi) + } + + private val apiProvider = ApiProvider( + apiManagerFactory = apiManagerFactory, + sessionProvider = sessionProvider, + dispatcherProvider = DefaultDispatcherProvider() + ) + + private val enqueuer: Enqueuer = mockk { + every { + this@mockk.enqueueUniqueWork( + userId = userId, + workerId = attachmentId.id, + params = DeleteAttachmentWorker.params(userId, attachmentId) + ) + } returns mockk() + } + private val workManager: WorkManager = mockk() + + private val attachmentRemoteDataSource by lazy { + AttachmentRemoteDataSourceImpl( + enqueuer, + workManager, + apiProvider + ) + } + + @Test + fun `upload attachment returns response when successful`() = runTest { + // Given + val expectedResource = UploadAttachmentResponse( + code = 1000, + attachment = AttachmentResource( + id = "test-id", + name = "test-name", + size = 123, + mimeType = "application/pdf", + signature = "test-signature", + encSignature = "test-enc-signature", + keyPackets = "test-key-packets", + headers = emptyMap() + ) + ) + expectUploadSuccessful(expectedResource) + + // When + val actual = attachmentRemoteDataSource.uploadAttachment( + userId = userId, + uploadAttachmentModel = UploadAttachmentModel( + messageId = MessageId("test-message-id"), + fileName = "test-file-name", + mimeType = "application/pdf", + attachment = EncryptedFile("test-attachment"), + keyPacket = ByteArray(0), + signature = ByteArray(0) + ) + ) + + // Then + assertEquals(expectedResource.right(), actual) + } + + @Test + fun `upload attachment returns error when failed`() = runTest { + // Given + expectUploadFailed() + + // When + val actual = attachmentRemoteDataSource.uploadAttachment( + userId = userId, + uploadAttachmentModel = UploadAttachmentModel( + messageId = MessageId("test-message-id"), + fileName = "test-file-name", + mimeType = "application/pdf", + attachment = EncryptedFile("test-attachment"), + keyPacket = ByteArray(0), + signature = ByteArray(0) + ) + ) + + // Then + assertEquals( + DataError.Remote.Http(NetworkError.NoNetwork, "No error message found", isRetryable = true).left(), + actual + ) + } + + @Test + fun `should enqueue work to delete attachment from draft`() = runTest { + // When + attachmentRemoteDataSource.deleteAttachmentFromDraft(userId, attachmentId) + + // Then + coVerify { + enqueuer.enqueueUniqueWork( + userId = userId, + workerId = attachmentId.id, + params = DeleteAttachmentWorker.params(userId, attachmentId) + ) + } + } + + @Test + fun `should cancel worker when cancelling attachment upload`() = runTest { + // Given + expectWorkManagerHasAttachmentUploadWorkerScheduled() + + // When + attachmentRemoteDataSource.cancelAttachmentUpload(attachmentId) + + // Then + coVerify { + workManager.cancelUniqueWork(attachmentId.id) + } + } + + @Test + fun `should ignore cancel request when no worker is scheduled`() = runTest { + // Given + expectWorkManagerHasNoAttachmentUploadWorkerScheduled() + + // When + attachmentRemoteDataSource.cancelAttachmentUpload(attachmentId) + + // Then + coVerify(exactly = 0) { workManager.cancelUniqueWork(attachmentId.id) } + } + + private fun expectWorkManagerHasAttachmentUploadWorkerScheduled() { + every { workManager.getWorkInfosForUniqueWork(attachmentId.id) } returns mockk { + every { get() } returns listOf( + mockk { + every { id } returns UUID.randomUUID() + } + ) + } + every { workManager.cancelUniqueWork(attachmentId.id) } returns mockk() + } + + private fun expectWorkManagerHasNoAttachmentUploadWorkerScheduled() { + every { workManager.getWorkInfosForUniqueWork(attachmentId.id) } returns mockk { + every { get() } returns emptyList() + } + } + + private fun expectUploadSuccessful(expectedResource: UploadAttachmentResponse) { + coEvery { + attachmentApi.uploadAttachment( + filename = any(), + messageID = any(), + mimeType = any(), + keyPackets = any(), + dataPacket = any(), + signature = any() + ) + } returns expectedResource + } + + private fun expectUploadFailed() { + coEvery { + attachmentApi.uploadAttachment( + filename = any(), + messageID = any(), + mimeType = any(), + keyPackets = any(), + dataPacket = any(), + signature = any() + ) + } throws UnknownHostException() + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorkerTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorkerTest.kt new file mode 100644 index 0000000000..d0ac5230ef --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DeleteAttachmentWorkerTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import java.net.UnknownHostException +import android.content.Context +import androidx.work.ListenableWorker.Result +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import me.proton.core.network.data.ApiManagerFactory +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.session.SessionId +import me.proton.core.network.domain.session.SessionProvider +import me.proton.core.test.android.api.TestApiManager +import me.proton.core.util.kotlin.DefaultDispatcherProvider +import okhttp3.ResponseBody.Companion.toResponseBody +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DeleteAttachmentWorkerTest { + + private val userId = UserIdSample.Primary + private val attachmentId = AttachmentId("attachment_id") + + private val workerManager: WorkManager = mockk { + coEvery { enqueue(any()) } returns mockk() + } + + private val parameters: WorkerParameters = mockk { + every { taskExecutor } returns mockk(relaxed = true) + every { inputData.getString(DeleteAttachmentWorker.RawUserIdKey) } returns userId.id + every { inputData.getString(DeleteAttachmentWorker.RawAttachmentIdKey) } returns attachmentId.id + } + private val context: Context = mockk() + + private val sessionProvider = mockk { + coEvery { getSessionId(userId) } returns SessionId("testSessionId") + } + + private val attachmentApi = mockk { + coEvery { deleteAttachment(attachmentId.id) } returns "ok".toResponseBody() + } + private val apiManagerFactory = mockk { + every { create(any(), AttachmentApi::class) } returns TestApiManager(attachmentApi) + } + + private lateinit var apiProvider: ApiProvider + private lateinit var worker: DeleteAttachmentWorker + + @BeforeTest + fun setUp() { + apiProvider = ApiProvider(apiManagerFactory, sessionProvider, DefaultDispatcherProvider()) + worker = DeleteAttachmentWorker(context, parameters, apiProvider) + } + + @Test + fun `worker is enqueued with given parameters`() { + // When + Enqueuer(workerManager).enqueue( + userId, + DeleteAttachmentWorker.params( + userId, + attachmentId + ) + ) + + // Then + val requestSlot = slot() + verify { workerManager.enqueue(capture(requestSlot)) } + val workSpec = requestSlot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val actualUserId = inputData.getString(DeleteAttachmentWorker.RawUserIdKey) + val actualAttachmentId = inputData.getString(DeleteAttachmentWorker.RawAttachmentIdKey) + assertEquals(userId.id, actualUserId) + assertEquals(attachmentId.id, actualAttachmentId) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + } + + @Test + fun `when delete attachment worker is executed then api is called with given parameters`() = runTest { + // When + worker.doWork() + + // Then + coEvery { attachmentApi.deleteAttachment(attachmentId.id) } + } + + @Test + fun `delete attachment worker fails when userId parameter is missing`() = runTest { + // Given + every { parameters.inputData.getString(DeleteAttachmentWorker.RawUserIdKey) } returns null + + // When - Then + assertFailsWith { worker.doWork() } + coVerify { attachmentApi wasNot Called } + } + + @Test + fun `delete attachment worker fails when attachmentId parameter is missing`() = runTest { + // Given + every { parameters.inputData.getString(DeleteAttachmentWorker.RawAttachmentIdKey) } returns null + + // When - Then + assertFailsWith { worker.doWork() } + coVerify { attachmentApi wasNot Called } + } + + @Test + fun `delete attachment worker returns success when api call was successful`() = runTest { + // When + val result = worker.doWork() + + // Then + assertEquals(Result.success(), result) + } + + @Test + fun `delete attachment worker returns retry when api call was not successful`() = runTest { + // Given + coEvery { attachmentApi.deleteAttachment(attachmentId.id) } throws UnknownHostException() + + // When + val result = worker.doWork() + + // Then + assertEquals(Result.retry(), result) + } + + @Test + fun `delete attachment worker returns failure when api call was not successful`() = runTest { + // Given + coEvery { attachmentApi.deleteAttachment(attachmentId.id) } throws SerializationException() + + // When + val result = worker.doWork() + + // Then + assertEquals(Result.failure(), result) + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceTest.kt new file mode 100644 index 0000000000..46671fa81d --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/DraftRemoteDataSourceTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.remote.resource.CreateDraftBody +import ch.protonmail.android.composer.data.remote.resource.UpdateDraftBody +import ch.protonmail.android.composer.data.remote.response.SaveDraftResponse +import ch.protonmail.android.composer.data.sample.CreateDraftBodySample +import ch.protonmail.android.composer.data.sample.MessageWithBodyResourceSample +import ch.protonmail.android.composer.data.sample.UpdateDraftBodySample +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.data.remote.resource.AttachmentResource +import ch.protonmail.android.mailmessage.data.remote.resource.MessageWithBodyResource +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.network.data.ApiManagerFactory +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.session.SessionId +import me.proton.core.network.domain.session.SessionProvider +import me.proton.core.test.android.api.TestApiManager +import me.proton.core.util.kotlin.DefaultDispatcherProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +class DraftRemoteDataSourceTest { + + private val sessionId = SessionId("testSessionId") + private val userId = UserIdSample.Primary + + private val draftApi = mockk() + private val apiManagerFactory = mockk { + every { create(any(), DraftApi::class) } returns TestApiManager(draftApi) + } + private val sessionProvider = mockk { + coEvery { getSessionId(userId) } returns sessionId + } + + private val apiProvider = ApiProvider( + apiManagerFactory = apiManagerFactory, + sessionProvider = sessionProvider, + dispatcherProvider = DefaultDispatcherProvider() + ) + + private val remoteDataSource = DraftRemoteDataSourceImpl(apiProvider = apiProvider) + + @Test + fun `create draft returns message with body when API call is successful`() = runTest { + // Given + val apiMessageId = MessageId("remote-api-assigned-messageId") + val action = DraftAction.Compose + val inputDraft = MessageWithBodySample.NewDraftWithSubjectAndBody + val expectedRequest = CreateDraftBodySample.NewDraftWithSubjectAndBody + val expectedApiResponse = MessageWithBodyResourceSample.NewDraftWithSubjectAndBody.copy(id = apiMessageId.id) + expectCreateDraftApiSucceeds(expectedRequest, expectedApiResponse) + + // When + val actual = remoteDataSource.create(userId, inputDraft, action) + + // Then + val expected = inputDraft.copy( + message = inputDraft.message.copy(messageId = apiMessageId), + messageBody = inputDraft.messageBody.copy(messageId = apiMessageId) + ) + assertEquals(expected.right(), actual) + } + + @Test + fun `update draft returns message with body when API call is successful`() = runTest { + // Given + val apiTime = 123L + val messageId = MessageIdSample.RemoteDraft + val inputDraft = MessageWithBodySample.RemoteDraft + val expectedRequest = UpdateDraftBodySample.RemoteDraft + // Change time to ensure difference between input and API response drafts (API sets time) + val expectedApiResponse = MessageWithBodyResourceSample.RemoteDraft.copy(time = apiTime) + expectUpdateDraftApiSucceeds(messageId, expectedRequest, expectedApiResponse) + + // When + val actual = remoteDataSource.update(userId, inputDraft) + + // Then + val expected = inputDraft.copy(message = inputDraft.message.copy(time = apiTime)) + assertEquals(expected.right(), actual) + } + + @Test + fun `create draft returns 'call is not performed' error without calling API when message has no body`() = runTest { + // Given + val action = DraftAction.Compose + val inputDraft = MessageWithBodySample.NewDraftWithSubject + + // When + val actual = remoteDataSource.create(userId, inputDraft, action) + + // Then + val expected = DataError.Remote.CreateDraftRequestNotPerformed + assertEquals(expected.left(), actual) + verify { draftApi wasNot Called } + } + + @Test + fun `when message has attachments with key packets they are passed to the API`() = runTest { + // Given + val parentMessageId = MessageId("parent-message-id") + val action = DraftAction.Forward(parentMessageId) + val inputDraft = MessageWithBodySample.MessageWithInvoiceAttachment + val expectedRequest = CreateDraftBodySample.NewDraftWithInvoiceAttachment.copy( + action = action.toApiInt(), parentId = action.getParentMessageId()!!.id + ) + val expectedApiResponse = MessageWithBodyResourceSample.NewDraftWithAttachments.copy( + attachments = inputDraft.messageBody.attachments.filter { it.keyPackets != null }.map { + AttachmentResource( + id = "Api-defined-id-${it.attachmentId}", + name = it.name, + size = it.size, + mimeType = it.mimeType, + keyPackets = "api-defined-key-packets-${it.keyPackets}", + headers = hashMapOf() + ) + } + ) + expectCreateDraftApiSucceeds(expectedRequest, expectedApiResponse) + + // When + val actual = remoteDataSource.create(userId, inputDraft, action) + + // Then + val expected = inputDraft.copy( + messageBody = inputDraft.messageBody.copy( + attachments = inputDraft.messageBody.attachments.map { + it.copy( + attachmentId = AttachmentId("Api-defined-id-${it.attachmentId}"), + keyPackets = "api-defined-key-packets-${it.keyPackets}" + ) + } + ) + ) + assertEquals(expected.right(), actual) + } + + private fun expectCreateDraftApiSucceeds(body: CreateDraftBody, expected: MessageWithBodyResource) { + coEvery { draftApi.createDraft(body) } returns SaveDraftResponse(code = ResponseCodes.OK, expected) + } + + private fun expectUpdateDraftApiSucceeds( + messageId: MessageId, + body: UpdateDraftBody, + expected: MessageWithBodyResource + ) { + coEvery { draftApi.updateDraft(messageId.id, body) } returns + SaveDraftResponse(code = ResponseCodes.OK, expected) + } + + companion object { + private object ResponseCodes { + const val OK = 1000 + } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorkerTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorkerTest.kt new file mode 100644 index 0000000000..f47029d62f --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/SendMessageWorkerTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.work.ListenableWorker.Result +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.usecase.SendMessage +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SendMessageWorkerTest { + + private val workManager: WorkManager = mockk { + coEvery { enqueue(any()) } returns mockk() + } + private val parameters: WorkerParameters = mockk { + every { this@mockk.getTaskExecutor() } returns mockk(relaxed = true) + } + private val context: Context = mockk() + private val sendMessageMock: SendMessage = mockk() + private val draftStateRepositoryMock: DraftStateRepository = mockk() + private val updateDraftStateForErrorMock: UpdateDraftStateForError = mockk() + + private val sendMessageWorker = SendMessageWorker( + context, + parameters, + sendMessageMock, + draftStateRepositoryMock, + updateDraftStateForErrorMock + ) + + @Test + fun `worker is enqueued with given parameters`() { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + + // When + Enqueuer(workManager).enqueue(userId, SendMessageWorker.params(userId, messageId)) + + // Then + val requestSlot = slot() + verify { workManager.enqueue(capture(requestSlot)) } + val workSpec = requestSlot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val actualUserId = inputData.getString(SendMessageWorker.RawUserIdKey) + val actualMessageIds = inputData.getString(SendMessageWorker.RawMessageIdKey) + assertEquals(userId.id, actualUserId) + assertEquals(messageId.id, actualMessageIds) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + } + + @Test + fun `worker returns failure and updates draft state for error when sendMessage fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val sendingError = SendingError.Other + givenInputData(userId, messageId) + givenSendMessageFailsWithDraftNotFound(userId, messageId) + givenUpdateDraftSyncStateSucceeds(userId, messageId, DraftSyncState.ErrorSending) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, sendingError) + + // When + val actual = sendMessageWorker.doWork() + + // Then + coVerify { updateDraftStateForErrorMock(userId, messageId, DraftSyncState.ErrorSending, sendingError) } + assertEquals(Result.failure(), actual) + } + + @Test + fun `worker updates draft state for error when sendMessage fails with a message already sent error`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val sendingError = SendingError.MessageAlreadySent + givenInputData(userId, messageId) + givenSendMessageFailsWithSendingToApiError(userId, messageId) + givenUpdateDraftSyncStateSucceeds(userId, messageId, DraftSyncState.ErrorSending) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, sendingError) + + // When + val actual = sendMessageWorker.doWork() + + // Then + coVerify { updateDraftStateForErrorMock(userId, messageId, DraftSyncState.ErrorSending, sendingError) } + assertEquals(Result.failure(), actual) + } + + @Test + fun `worker returns success and updates DraftSyncState when sendMessage succeeds`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + givenSendMessageSucceeds(userId, messageId) + givenUpdateDraftSyncStateSucceeds(userId, messageId, DraftSyncState.Sent) + + // When + val actual = sendMessageWorker.doWork() + + // Then + coVerify { draftStateRepositoryMock.updateDraftSyncState(userId, messageId, DraftSyncState.Sent) } + assertEquals(Result.success(), actual) + } + + @Test + fun `worker fails when userid worker parameter is missing`() = runTest { + // Given + val userId = null + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { sendMessageWorker.doWork() } + coVerify { sendMessageMock wasNot Called } + } + + @Test + fun `worker fails when messageIds worker parameter is empty`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageId("") + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { sendMessageWorker.doWork() } + coVerify { sendMessageMock wasNot Called } + } + + private fun givenUpdateDraftSyncStateSucceeds( + userId: UserId, + messageId: MessageId, + syncState: DraftSyncState + ) { + coEvery { draftStateRepositoryMock.updateDraftSyncState(userId, messageId, syncState) } returns Unit.right() + } + + private fun givenUpdateDraftStateForErrorSucceeds( + userId: UserId, + messageId: MessageId, + sendingError: SendingError? + ) { + coJustRun { updateDraftStateForErrorMock(userId, messageId, DraftSyncState.ErrorSending, sendingError) } + } + + private fun givenSendMessageFailsWithDraftNotFound(userId: UserId, messageId: MessageId) { + coEvery { sendMessageMock(userId, messageId) } returns SendMessage.Error.DraftNotFound.left() + } + + private fun givenSendMessageFailsWithSendingToApiError(userId: UserId, messageId: MessageId) { + coEvery { + sendMessageMock(userId, messageId) + } returns SendMessage.Error.SendingToApi(DataError.Remote.Proton(ProtonError.MessageAlreadySent)).left() + } + + private fun givenSendMessageSucceeds(userId: UserId, messageId: MessageId) { + coEvery { sendMessageMock(userId, messageId) } returns Unit.right() + } + + private fun givenInputData(userId: UserId?, messageId: MessageId?) { + every { parameters.inputData.getString(SendMessageWorker.RawUserIdKey) } returns userId?.id + every { parameters.inputData.getString(SendMessageWorker.RawMessageIdKey) } returns messageId?.id + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorkerTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorkerTest.kt new file mode 100644 index 0000000000..27ab5d5a4c --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadAttachmentsWorkerTest.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.usecase.AttachmentUploadError +import ch.protonmail.android.composer.data.usecase.UploadAttachments +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class UploadAttachmentsWorkerTest { + + private val workManager: WorkManager = mockk { + coEvery { enqueue(any()) } returns mockk() + } + private val parameters: WorkerParameters = mockk { + every { this@mockk.getTaskExecutor() } returns mockk(relaxed = true) + } + private val context: Context = mockk() + private val uploadAttachments: UploadAttachments = mockk() + private val updateDraftStateForError: UpdateDraftStateForError = mockk() + + private val uploadAttachmentWorker = + UploadAttachmentsWorker(context, parameters, uploadAttachments, updateDraftStateForError) + + @Test + fun `worker is enqueued with given parameters`() { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + + // When + Enqueuer(workManager).enqueue( + userId, UploadAttachmentsWorker.params(userId, messageId) + ) + + // Then + val requestSlot = slot() + verify { workManager.enqueue(capture(requestSlot)) } + val workSpec = requestSlot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val actualUserId = inputData.getString(UploadAttachmentsWorker.RawUserIdKey) + val actualMessageIds = inputData.getString(UploadAttachmentsWorker.RawMessageIdKey) + assertEquals(userId.id, actualUserId) + assertEquals(messageId.id, actualMessageIds) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + } + + @Test + fun `worker returns success when sync draft succeeds`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + givenUploadAttachmentsSucceeds(userId, messageId) + + // When + val actual = uploadAttachmentWorker.doWork() + + // Then + coVerify { uploadAttachments(userId, messageId) } + assertEquals(ListenableWorker.Result.success(), actual) + } + + @Test + fun `worker fails when userid worker parameter is missing`() = runTest { + // Given + val userId = null + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { uploadAttachmentWorker.doWork() } + coVerify { uploadAttachments wasNot Called } + } + + @Test + fun `worker fails when messageIds worker parameter is empty`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageId("") + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { uploadAttachmentWorker.doWork() } + coVerify { uploadAttachments wasNot Called } + } + + @Test + fun `worker updates draft state for error when upload attachment fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + givenUploadAttachmentsFails( + userId, messageId, AttachmentUploadError.UploadFailed(DataError.Remote.Proton(ProtonError.UploadFailure)) + ) + givenUpdateDraftStateForErrorSucceeds(userId, messageId) + + // When + val actual = uploadAttachmentWorker.doWork() + + // Then + coVerify { uploadAttachments(userId, messageId) } + coVerify { updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadAttachments) } + assertEquals(ListenableWorker.Result.failure(), actual) + } + + @Test + fun `worker updates draft state for error when upload attachment fails with a message already sent error`() = + runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + givenUploadAttachmentsFails( + userId, + messageId, + AttachmentUploadError.UploadFailed( + DataError.Remote.Proton(ProtonError.AttachmentUploadMessageAlreadySent) + ) + ) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, SendingError.MessageAlreadySent) + + // When + val actual = uploadAttachmentWorker.doWork() + + // Then + coVerify { uploadAttachments(userId, messageId) } + coVerify { + updateDraftStateForError( + userId, + messageId, + DraftSyncState.ErrorUploadAttachments, + SendingError.MessageAlreadySent + ) + } + assertEquals(ListenableWorker.Result.failure(), actual) + } + + private fun givenUploadAttachmentsSucceeds(userId: UserId, messageId: MessageId) { + coEvery { uploadAttachments(userId, messageId) } returns Unit.right() + } + + private fun givenUpdateDraftStateForErrorSucceeds( + userId: UserId, + messageId: MessageId, + sendingError: SendingError? = null + ) { + coJustRun { updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadAttachments, sendingError) } + } + + private fun givenUploadAttachmentsFails( + userId: UserId, + messageId: MessageId, + error: AttachmentUploadError + ) { + coEvery { uploadAttachments(userId, messageId) } returns error.left() + } + + private fun givenInputData(userId: UserId?, messageId: MessageId?) { + every { parameters.inputData.getString(UploadAttachmentsWorker.RawUserIdKey) } returns userId?.id + every { parameters.inputData.getString(UploadAttachmentsWorker.RawMessageIdKey) } returns messageId?.id + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerChainTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerChainTest.kt new file mode 100644 index 0000000000..0978c445ef --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerChainTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import java.util.UUID +import android.content.Context +import androidx.work.ListenableWorker.Result +import androidx.work.OneTimeWorkRequest +import androidx.work.Operation +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import arrow.core.right +import ch.protonmail.android.composer.data.extension.awaitCompletion +import ch.protonmail.android.composer.data.usecase.UploadDraft +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import com.google.common.util.concurrent.ListenableFuture +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class UploadDraftWorkerChainTest( + @Suppress("UNUSED_PARAMETER") testName: String, + private val testInput: TestInput +) { + + private val workManager: WorkManager = mockk { + coEvery { enqueueUniqueWork(any(), any(), any()) } returns mockk() + } + private val workerParameters: WorkerParameters = mockk { + every { this@mockk.taskExecutor } returns mockk(relaxed = true) + every { this@mockk.tags } returns emptySet() + } + private val context: Context = mockk() + private val uploadDraft: UploadDraft = mockk { + coEvery { this@mockk(any(), any()) } returns Unit.right() + } + private val updateDraftStateForError: UpdateDraftStateForError = mockk() + + private val listenableFutureMock = mockk?>>() + + private val uploadDraftWorker = UploadDraftWorker( + context, + workerParameters, + workManager, + uploadDraft, + updateDraftStateForError + ) + + @BeforeTest + fun setup() { + mockkStatic("ch.protonmail.android.composer.data.extension.ListenableFutureExtensionsKt") + } + + @AfterTest + fun teardown() { + mockkStatic("ch.protonmail.android.composer.data.extension.ListenableFutureExtensionsKt") + } + + @Test + fun test() = runTest { + val userId = UserIdSample.Primary + val messageId = MessageId("messageId") + val uploadWorkerId = UploadDraftWorker.id(messageId) + val sendUploadWorkerId = UploadDraftWorker.sendId(messageId) + + every { workerParameters.tags.contains(sendUploadWorkerId) } returns true + coEvery { workManager.cancelUniqueWork(uploadWorkerId) } returns mockk { + every { result.get() } returns Operation.SUCCESS + every { result.isDone } returns true + } + coEvery { workManager.getWorkInfosForUniqueWork(uploadWorkerId) } returns listenableFutureMock + + givenInputData(userId, messageId) + givenExistingUploadWorkWithState(testInput.existingState) + + // When + val actual = uploadDraftWorker.doWork() + + // Then + if (testInput.shouldCancelExistingWork) { + verify(exactly = 1) { workManager.cancelUniqueWork(uploadWorkerId) } + } else { + verify(exactly = 0) { workManager.cancelUniqueWork(any()) } + } + + if (testInput.shouldRunImmediately) { + coVerify(exactly = 1) { uploadDraft(userId, messageId) } + } else { + verify { uploadDraft wasNot called } + } + + verify(exactly = 1) { workManager.getWorkInfosForUniqueWork(uploadWorkerId) } + + confirmVerified(workManager, uploadDraft) + assertEquals(testInput.expectedResult, actual) + } + + private fun givenExistingUploadWorkWithState(state: WorkInfo.State?) { + val expectedList = state?.let { + mutableListOf( + WorkInfo(id = UUID.randomUUID(), state = state, setOf()) + ) + } ?: mutableListOf() + + coEvery { + listenableFutureMock.awaitCompletion() + } returns expectedList + } + + private fun givenInputData(userId: UserId?, messageId: MessageId?) { + every { workerParameters.inputData.getString(UploadDraftWorker.RawUserIdKey) } returns userId?.id + every { workerParameters.inputData.getString(UploadDraftWorker.RawMessageIdKey) } returns messageId?.id + } + + companion object { + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + TestInput( + testName = "should cancel enqueued upload draft job and return retry", + existingState = WorkInfo.State.ENQUEUED, + shouldCancelExistingWork = true, + shouldRunImmediately = false, + expectedResult = Result.retry() + ), + TestInput( + testName = "should cancel blocked upload draft job and return retry", + existingState = WorkInfo.State.BLOCKED, + shouldCancelExistingWork = true, + shouldRunImmediately = false, + expectedResult = Result.retry() + ), + TestInput( + testName = "should not cancel running upload draft job and return retry", + existingState = WorkInfo.State.RUNNING, + shouldCancelExistingWork = false, + shouldRunImmediately = false, + expectedResult = Result.retry() + ), + TestInput( + testName = "should not cancel running upload draft job and run normally", + existingState = WorkInfo.State.SUCCEEDED, + shouldCancelExistingWork = false, + shouldRunImmediately = true, + expectedResult = Result.success() + ), + + TestInput( + testName = "should not cancel failed upload draft job and run normally", + existingState = WorkInfo.State.FAILED, + shouldCancelExistingWork = false, + shouldRunImmediately = true, + expectedResult = Result.success() + ), + TestInput( + testName = "should not cancel cancelled upload draft job and run normally", + existingState = WorkInfo.State.CANCELLED, + shouldCancelExistingWork = false, + shouldRunImmediately = true, + expectedResult = Result.success() + ), + TestInput( + testName = "should not call cancel if work info is null and run immediately", + existingState = null, + shouldCancelExistingWork = false, + shouldRunImmediately = true, + expectedResult = Result.success() + ) + ).map { arrayOf(it.testName, it) } + + data class TestInput( + val testName: String, + val existingState: WorkInfo.State?, + val shouldCancelExistingWork: Boolean, + val shouldRunImmediately: Boolean, + val expectedResult: Result + ) + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerTest.kt new file mode 100644 index 0000000000..89bd150060 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/remote/UploadDraftWorkerTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.remote + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker.Result +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.usecase.UploadDraft +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.NetworkError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.usecase.UpdateDraftStateForError +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +internal class UploadDraftWorkerTest { + + private val workManager: WorkManager = mockk { + coEvery { enqueueUniqueWork(any(), any(), any()) } returns mockk() + } + private val workerParameters: WorkerParameters = mockk { + every { this@mockk.taskExecutor } returns mockk(relaxed = true) + every { this@mockk.tags } returns emptySet() + } + private val context: Context = mockk() + private val uploadDraft: UploadDraft = mockk() + private val updateDraftStateForError: UpdateDraftStateForError = mockk() + + private val uploadDraftWorker = UploadDraftWorker( + context, + workerParameters, + workManager, + uploadDraft, + updateDraftStateForError + ) + + @Test + fun `worker is enqueued with given parameters`() { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val workerId = "UploadWorker-workerId" + val workerPolicy = ExistingWorkPolicy.REPLACE + givenInputData(userId, messageId) + + // When + Enqueuer(workManager).enqueueUniqueWork( + userId = userId, + workerId = workerId, + existingWorkPolicy = workerPolicy, + params = UploadDraftWorker.params(userId, messageId) + ) + + // Then + val workerIdSlot = slot() + val existingPolicySlot = slot() + val requestSlot = slot() + verify { + workManager.enqueueUniqueWork( + capture(workerIdSlot), + capture(existingPolicySlot), + capture(requestSlot) + ) + } + + val workSpec = requestSlot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val capturedPolicy = existingPolicySlot.captured + val tags = requestSlot.captured.tags + val actualUserId = inputData.getString(UploadDraftWorker.RawUserIdKey) + val actualMessageIds = inputData.getString(UploadDraftWorker.RawMessageIdKey) + + assertEquals(userId.id, actualUserId) + assertEquals(messageId.id, actualMessageIds) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + assertEquals(workerPolicy, capturedPolicy) + assertTrue(tags.contains(workerId)) + } + + @Test + fun `worker returns success when sync draft succeeds`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + givenUploadDraftSucceeds(userId, messageId) + + // When + val actual = uploadDraftWorker.doWork() + + // Then + coVerify { uploadDraft(userId, messageId) } + assertEquals(Result.success(), actual) + } + + @Test + fun `worker fails when userid worker parameter is missing`() = runTest { + // Given + val userId = null + val messageId = MessageIdSample.LocalDraft + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { uploadDraftWorker.doWork() } + coVerify { uploadDraft wasNot Called } + } + + @Test + fun `worker fails when messageIds worker parameter is empty`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageId("") + givenInputData(userId, messageId) + + // When - Then + assertFailsWith { uploadDraftWorker.doWork() } + coVerify { uploadDraft wasNot Called } + } + + @Test + fun `worker fails and updates draft state for error when upload draft fails with unretryable error`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val sendingError: SendingError? = null + givenInputData(userId, messageId) + givenUploadDraftFailsWithUnretryableError(userId, messageId) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, sendingError) + + // When + uploadDraftWorker.doWork() + + // Then + coVerify { updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadDraft, sendingError) } + } + + @Test + fun `worker returns a retry result when upload draft fails with retryable error`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val sendingError: SendingError? = null + givenInputData(userId, messageId) + givenUploadDraftFailsWithRetryableError(userId, messageId) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, sendingError) + + // When + val actual = uploadDraftWorker.doWork() + + // Then + assertEquals(Result.retry(), actual) + } + + @Test + fun `worker fails and updates draft state passing sending error when failure is Message already sent`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val sendingError = SendingError.MessageAlreadySent + givenInputData(userId, messageId) + givenUploadDraftFailsWithMessageAlreadySentError(userId, messageId) + givenUpdateDraftStateForErrorSucceeds(userId, messageId, sendingError) + + // When + uploadDraftWorker.doWork() + + // Then + coVerify { updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadDraft, sendingError) } + } + + private fun givenUploadDraftSucceeds(userId: UserId, messageId: MessageId) { + coEvery { uploadDraft(userId, messageId) } returns Unit.right() + } + + private fun givenUploadDraftFailsWithUnretryableError(userId: UserId, messageId: MessageId) { + coEvery { uploadDraft(userId, messageId) } returns DataError.Remote.Http(NetworkError.Forbidden).left() + } + + private fun givenUploadDraftFailsWithRetryableError(userId: UserId, messageId: MessageId) { + coEvery { + uploadDraft(userId, messageId) + } returns DataError.Remote.Http(NetworkError.ServerError, isRetryable = true).left() + } + + private fun givenUploadDraftFailsWithMessageAlreadySentError(userId: UserId, messageId: MessageId) { + coEvery { + uploadDraft(userId, messageId) + } returns DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft).left() + } + + private fun givenInputData(userId: UserId?, messageId: MessageId?) { + every { workerParameters.inputData.getString(UploadDraftWorker.RawUserIdKey) } returns userId?.id + every { workerParameters.inputData.getString(UploadDraftWorker.RawMessageIdKey) } returns messageId?.id + } + + private fun givenUpdateDraftStateForErrorSucceeds( + userId: UserId, + messageId: MessageId, + sendingError: SendingError? + ) { + coJustRun { updateDraftStateForError(userId, messageId, DraftSyncState.ErrorUploadDraft, sendingError) } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImplTest.kt new file mode 100644 index 0000000000..95586f8830 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentRepositoryImplTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSource +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSource +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.AttachmentStateSample +import ch.protonmail.android.mailmessage.data.local.AttachmentLocalDataSource +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachmentRepositoryImplTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.MessageWithAttachments + private val attachmentId = AttachmentId("attachmentId") + private val fileName = "fileName.txt" + private val mimeType = "text/plain" + private val byteContent = "Content of a text file".toByteArray() + + private val attachmentStateLocalDataSource = mockk() + private val attachmentRemoteDataSource = mockk { + coJustRun { deleteAttachmentFromDraft(userId, attachmentId) } + } + private val attachmentLocalDataSource = mockk() + + private val attachmentRepositoryImpl = AttachmentRepositoryImpl( + attachmentStateLocalDataSource, + attachmentRemoteDataSource, + attachmentLocalDataSource + ) + + @Test + fun `when uploaded attachment is deleted then remote layer is called before delete with file locally is called`() = + runTest { + // Given + val expected = Unit.right() + expectLoadingAttachmentStateLoadsUploadedStateSuccessful() + expectDeleteAttachmentWithFileLocalSuccessful() + + // When + val actual = attachmentRepositoryImpl.deleteAttachment(userId, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerifyOrder { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + attachmentRemoteDataSource.deleteAttachmentFromDraft(userId, attachmentId) + attachmentLocalDataSource.deleteAttachmentWithFile(userId, messageId, attachmentId) + } + } + + @Test + fun `when parent uploaded attachment is deleted then remote layer is executed before delete locally is called`() = + runTest { + // Given + val expected = Unit.right() + expectLoadingAttachmentStateLoadsParentUploadedStateSuccessful() + expectDeleteAttachmentLocalSuccessful() + + // When + val actual = attachmentRepositoryImpl.deleteAttachment(userId, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerifyOrder { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + attachmentRemoteDataSource.deleteAttachmentFromDraft(userId, attachmentId) + attachmentLocalDataSource.deleteAttachment(userId, messageId, attachmentId) + } + } + + @Test + fun `deleting file cancel worker when attachment state is not uploaded`() = runTest { + // Given + val expected = Unit.right() + expectLoadingAttachmentStateLoadsLocalStateSuccessful() + expectCancelAttachmentWorkerSucceeds() + expectDeleteAttachmentWithFileLocalSuccessful() + + // When + val actual = attachmentRepositoryImpl.deleteAttachment(userId, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerifyOrder { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + attachmentRemoteDataSource.cancelAttachmentUpload(attachmentId) + attachmentLocalDataSource.deleteAttachmentWithFile(userId, messageId, attachmentId) + } + } + + @Test + fun `returns local error when deleting file locally fails`() = runTest { + // Given + val expected = DataError.Local.FailedToDeleteFile.left() + expectLoadingAttachmentStateLoadsUploadedStateSuccessful() + expectDeleteAttachmentLocalFailed() + + // When + val actual = attachmentRepositoryImpl.deleteAttachment(userId, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerifyOrder { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + attachmentRemoteDataSource.deleteAttachmentFromDraft(userId, attachmentId) + attachmentLocalDataSource.deleteAttachmentWithFile(userId, messageId, attachmentId) + } + } + + @Test + fun `returns local error when attachment state is not found`() = runTest { + // Given + val expected = DataError.Local.NoDataCached.left() + expectLoadingAttachmentStateFailed() + + // When + val actual = attachmentRepositoryImpl.deleteAttachment(userId, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerify { attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) } + coVerify { attachmentRemoteDataSource wasNot Called } + coVerify { attachmentLocalDataSource wasNot Called } + } + + @Test + fun `returns local error when storing file locally fails`() = runTest { + // Given + val expected = DataError.Local.FailedToStoreFile.left() + expectUpsertAttachmentLocalFailed() + + // When + val actual = attachmentRepositoryImpl.createAttachment( + userId, + messageId, + attachmentId, + fileName, + mimeType, + byteContent + ) + + // Then + assertEquals(expected, actual) + coVerifyOrder { + attachmentLocalDataSource.upsertAttachment(userId, messageId, attachmentId, fileName, mimeType, byteContent) + } + } + + @Test + fun `returns local error when createOrUpdate AttachmentState failed`() = runTest { + // Given + expectUpsertAttachmentLocalSuccessful() + val expectedAttachmentState = AttachmentStateSample.build( + userId, + messageId, + attachmentId, + AttachmentSyncState.Local + ) + expectCreateOrUpdateAttachmentStateFailed(expectedAttachmentState) + + // When + val actual = attachmentRepositoryImpl.createAttachment( + userId, + messageId, + attachmentId, + fileName, + mimeType, + byteContent + ) + + // Then + assert(actual.isLeft()) + assert(actual.leftOrNull() is DataError.Local) + coVerifyOrder { + attachmentLocalDataSource.upsertAttachment(userId, messageId, attachmentId, fileName, mimeType, byteContent) + attachmentStateLocalDataSource.createOrUpdate(expectedAttachmentState) + } + } + + @Test + fun `when creating Attachment, stores a file on disk and creates corresponding AttachmentState`() = runTest { + // Given + expectUpsertAttachmentLocalSuccessful() + val expectedAttachmentState = AttachmentStateSample.build( + userId, + messageId, + attachmentId, + AttachmentSyncState.Local + ) + expectCreateOrUpdateAttachmentStateSuccessful(expectedAttachmentState) + + // When + val actual = attachmentRepositoryImpl.createAttachment( + userId, + messageId, + attachmentId, + fileName, + mimeType, + byteContent + ) + + // Then + assertEquals(Unit.right(), actual) + coVerifyOrder { + attachmentLocalDataSource.upsertAttachment(userId, messageId, attachmentId, fileName, mimeType, byteContent) + attachmentStateLocalDataSource.createOrUpdate(expectedAttachmentState) + } + } + + + private fun expectLoadingAttachmentStateLoadsUploadedStateSuccessful() { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns AttachmentStateSample.RemoteAttachmentState.right() + } + + private fun expectLoadingAttachmentStateLoadsParentUploadedStateSuccessful() { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns AttachmentStateSample.RemoteAttachmentState.copy(state = AttachmentSyncState.ExternalUploaded).right() + } + + private fun expectLoadingAttachmentStateLoadsLocalStateSuccessful() { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns AttachmentStateSample.LocalAttachmentState.right() + } + + private fun expectCancelAttachmentWorkerSucceeds() { + coJustRun { attachmentRemoteDataSource.cancelAttachmentUpload(attachmentId) } + } + + private fun expectLoadingAttachmentStateFailed() { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectDeleteAttachmentWithFileLocalSuccessful() { + coEvery { + attachmentLocalDataSource.deleteAttachmentWithFile( + userId, + messageId, + attachmentId + ) + } returns Unit.right() + } + + private fun expectDeleteAttachmentLocalSuccessful() { + coEvery { + attachmentLocalDataSource.deleteAttachment(userId, messageId, attachmentId) + } returns Unit.right() + } + + private fun expectDeleteAttachmentLocalFailed() { + coEvery { + attachmentLocalDataSource.deleteAttachmentWithFile(userId, messageId, attachmentId) + } returns DataError.Local.FailedToDeleteFile.left() + } + + private fun expectUpsertAttachmentLocalFailed() { + coEvery { + attachmentLocalDataSource.upsertAttachment(userId, messageId, attachmentId, fileName, mimeType, byteContent) + } returns DataError.Local.FailedToStoreFile.left() + } + + private fun expectUpsertAttachmentLocalSuccessful() { + coEvery { + attachmentLocalDataSource.upsertAttachment(userId, messageId, attachmentId, fileName, mimeType, byteContent) + } returns Unit.right() + } + + private fun expectCreateOrUpdateAttachmentStateFailed(attachmentState: AttachmentState) { + coEvery { + attachmentStateLocalDataSource.createOrUpdate(attachmentState) + } returns DataError.Local.Unknown.left() + } + + private fun expectCreateOrUpdateAttachmentStateSuccessful(attachmentState: AttachmentState) { + coEvery { + attachmentStateLocalDataSource.createOrUpdate(attachmentState) + } returns Unit.right() + } +} + diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImplTest.kt new file mode 100644 index 0000000000..e8bdacb34f --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/AttachmentStateRepositoryImplTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.AttachmentStateLocalDataSourceImpl +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.AttachmentStateSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals + +class AttachmentStateRepositoryImplTest { + + private val userId = UserIdSample.Primary + private val attachmentId = AttachmentId("attachment_id") + + private val attachmentStateLocalDataSource = mockk() + + private val repository = AttachmentStateRepositoryImpl(attachmentStateLocalDataSource) + + @Test + fun `get attachment state returns it when existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expected = AttachmentStateSample.LocalAttachmentState + expectAttachmentStateLocalDataSourceSuccess(userId, draftId, attachmentId, expected) + + // When + val actual = repository.getAttachmentState(userId, draftId, attachmentId) + + // Then + assertEquals(expected.right(), actual) + } + + @Test + fun `get attachment state returns no data cached error when not existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expected = DataError.Local.NoDataCached.left() + expectAttachmentStateLocalDataSourceNoData(userId, draftId, attachmentId) + + // When + val actual = repository.getAttachmentState(userId, draftId, attachmentId) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `get all attachment states for message returns them when existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expected = listOf(AttachmentStateSample.LocalAttachmentState) + expectGetAllAttachmentStatesReturnsListWithEntries(draftId, expected) + + // When + val actual = repository.getAllAttachmentStatesForMessage(userId, draftId) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `get all attachment states for message returns empty list when not existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expected = emptyList() + expectGetAllAttachmentStatesReturnsListWithEntries(draftId, expected) + + // When + val actual = repository.getAllAttachmentStatesForMessage(userId, draftId) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `set attachment upload state does update attachment sync state`() = runTest { + // Given + val draftId = MessageIdSample.RemoteDraft + val existingAttachmentState = AttachmentStateSample.LocalAttachmentState + val expectedAttachmentState = existingAttachmentState.copy(state = AttachmentSyncState.Uploaded) + expectAttachmentStateLocalDataSourceSuccess(userId, draftId, attachmentId, existingAttachmentState) + expectLocalDataSourceUpsertSuccess(expectedAttachmentState) + + // When + val actual = repository.setAttachmentToUploadState( + userId, + draftId, + expectedAttachmentState.attachmentId + ) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `store synced state calls attachment state local data source`() = runTest { + // Given + val expectedAttachmentState = AttachmentStateSample.LocalAttachmentState + val messageId = MessageIdSample.RemoteDraft + expectLocalDataSourceUpsertSuccess(expectedAttachmentState) + + // When + val actual = repository.createOrUpdateLocalState(userId, messageId, attachmentId) + + // Then + coVerify { attachmentStateLocalDataSource.createOrUpdate(expectedAttachmentState) } + assertEquals(Unit.right(), actual) + } + + @Test + fun `store synced states calls attachment state local data source for multiple states`() = runTest { + // Given + val attachmentId1 = AttachmentId("1") + val attachmentId2 = AttachmentId("2") + val expectedAttachmentState = AttachmentStateSample.LocalAttachmentState.copy(attachmentId = attachmentId1) + val expectedAttachmentState2 = AttachmentStateSample.LocalAttachmentState.copy(attachmentId = attachmentId2) + val messageId = MessageIdSample.RemoteDraft + expectLocalDataSourceUpsertSuccess(listOf(expectedAttachmentState, expectedAttachmentState2)) + + // When + val actual = repository.createOrUpdateLocalStates( + userId = userId, + messageId = messageId, + attachmentIds = listOf(attachmentId1, attachmentId2), + syncState = AttachmentSyncState.Local + ) + + // Then + coVerify { + attachmentStateLocalDataSource.createOrUpdate(listOf(expectedAttachmentState, expectedAttachmentState2)) + } + assertEquals(Unit.right(), actual) + } + + @Test + fun `store synced state stores updates attachment id in attachment sync state`() = runTest { + // Given + val localAttachmentState = AttachmentStateSample.LocalAttachmentState + val expectedAttachmentState = AttachmentStateSample.RemoteAttachmentState + val messageId = MessageIdSample.RemoteDraft + expectAttachmentStateLocalDataSourceSuccess(userId, messageId, attachmentId, localAttachmentState) + expectLocalDataSourceUpsertSuccess(expectedAttachmentState) + + // When + val actual = repository.setAttachmentToUploadState( + userId = userId, + messageId = messageId, + attachmentId = expectedAttachmentState.attachmentId + ) + + // Then + coVerify { attachmentStateLocalDataSource.createOrUpdate(expectedAttachmentState) } + assertEquals(Unit.right(), actual) + } + + @Test + fun `should delete attachment state when existing`() = runTest { + // Given + val messageId = MessageIdSample.RemoteDraft + val expectedAttachmentState = AttachmentStateSample.LocalAttachmentState + expectAttachmentStateLocalDataSourceSuccess(userId, messageId, attachmentId, expectedAttachmentState) + expectedDeleteSuccess(expectedAttachmentState) + + // When + val actual = repository.deleteAttachmentState(userId, messageId, attachmentId) + + // Then + coVerify { attachmentStateLocalDataSource.delete(expectedAttachmentState) } + assertEquals(Unit.right(), actual) + } + + private fun expectAttachmentStateLocalDataSourceSuccess( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId, + expected: AttachmentState + ) { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns expected.right() + } + + private fun expectAttachmentStateLocalDataSourceNoData( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ) { + coEvery { + attachmentStateLocalDataSource.getAttachmentState(userId, messageId, attachmentId) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectGetAllAttachmentStatesReturnsListWithEntries( + draftId: MessageId, + expected: List + ) { + coEvery { attachmentStateLocalDataSource.getAllAttachmentStatesForMessage(userId, draftId) } returns expected + } + + private fun expectLocalDataSourceUpsertSuccess(state: AttachmentState) { + coEvery { attachmentStateLocalDataSource.createOrUpdate(state) } returns Unit.right() + } + + private fun expectLocalDataSourceUpsertSuccess(states: List) { + coEvery { attachmentStateLocalDataSource.createOrUpdate(states) } returns Unit.right() + } + + private fun expectedDeleteSuccess(state: AttachmentState) { + coJustRun { attachmentStateLocalDataSource.delete(state) } + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImplTest.kt new file mode 100644 index 0000000000..5f5e2de978 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftRepositoryImplTest.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import ch.protonmail.android.composer.data.remote.UploadAttachmentsWorker +import ch.protonmail.android.composer.data.remote.UploadDraftWorker +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test + +class DraftRepositoryImplTest { + + private val enqueuer = mockk() + private val draftUploadTracker = mockk() + private val workManager = mockk() + + private val draftRepository = DraftRepositoryImpl(enqueuer, workManager, draftUploadTracker) + + @Test + fun `upload enqueue upload draft work when not already enqueued and upload tracker requires upload`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val expectedParams = UploadDraftWorker.params(userId, messageId) + val expectedWorkerId = UploadDraftWorker.id(messageId) + val expectedWorkPolicy = ExistingWorkPolicy.KEEP + givenEnqueuerSucceeds(userId, expectedWorkerId, expectedParams, expectedWorkPolicy) + givenUploadTrackerRequiresUpload(userId, messageId) + + // When + draftRepository.upload(userId, messageId) + + // Then + verify { + enqueuer.enqueueUniqueWork( + userId, + workerId = expectedWorkerId, + params = expectedParams, + existingWorkPolicy = expectedWorkPolicy + ) + } + } + + @Test + fun `should not enqueue upload draft work when upload tracker does not require upload`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + givenUploadTrackerDoesNotRequireUpload(userId, messageId) + + // When + draftRepository.upload(userId, messageId) + + // Then + verify(exactly = 0) { + enqueuer.enqueueUniqueWork( + userId, any(), any(), any() + ) + } + } + + @Test + fun `force upload enqueue upload draft work also if existing`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val expectedParamsDraftWorker = UploadDraftWorker.params(userId, messageId) + val expectedParamsAttachmentsWorker = UploadAttachmentsWorker.params(userId, messageId) + val expectedWorkerId = UploadDraftWorker.id(messageId) + // This work should happen independently on the outcome of the previous one + val expectedWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE + givenChainEnqueuerSucceeds( + userId = userId, + workId = expectedWorkerId, + expectedParams1 = expectedParamsDraftWorker, + expectedParams2 = expectedParamsAttachmentsWorker, + existingWorkPolicy = expectedWorkPolicy + ) + + // When + draftRepository.forceUpload(userId, messageId) + + // Then + verify { + enqueuer.enqueueInChain( + userId = userId, + uniqueWorkId = expectedWorkerId, + params1 = expectedParamsDraftWorker, + params2 = expectedParamsAttachmentsWorker, + existingWorkPolicy = expectedWorkPolicy + ) + } + } + + @Test + fun `cancel upload draft work`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val uniqueWorkId = UploadDraftWorker.id(messageId) + every { workManager.cancelUniqueWork(uniqueWorkId) } returns mockk() + + // When + draftRepository.cancelUploadDraft(messageId) + + // Then + verify { workManager.cancelUniqueWork(uniqueWorkId) } + } + + private fun givenUploadTrackerRequiresUpload(userId: UserId, messageId: MessageId) { + coEvery { + draftUploadTracker.uploadRequired(userId, messageId) + } returns true + } + + private fun givenUploadTrackerDoesNotRequireUpload(userId: UserId, messageId: MessageId) { + coEvery { + draftUploadTracker.uploadRequired(userId, messageId) + } returns false + } + + private fun givenEnqueuerSucceeds( + userId: UserId, + workId: String, + expectedParams: Map, + existingWorkPolicy: ExistingWorkPolicy + ) { + every { + enqueuer.enqueueUniqueWork( + userId = userId, + workerId = workId, + params = expectedParams, + existingWorkPolicy = existingWorkPolicy + ) + } returns Unit + } + + private fun givenChainEnqueuerSucceeds( + userId: UserId, + workId: String, + expectedParams1: Map, + expectedParams2: Map, + existingWorkPolicy: ExistingWorkPolicy + ) { + every { + enqueuer.enqueueInChain( + userId = userId, + uniqueWorkId = workId, + params1 = expectedParams1, + params2 = expectedParams2, + existingWorkPolicy = existingWorkPolicy + ) + } returns Unit + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImplTest.kt new file mode 100644 index 0000000000..b00c3d7135 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/DraftStateRepositoryImplTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.local.DraftStateLocalDataSourceImpl +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals + +class DraftStateRepositoryImplTest { + + private val userId = UserIdSample.Primary + + private val draftStateLocalDataSource = mockk() + + private val repository = DraftStateRepositoryImpl(draftStateLocalDataSource) + + @Test + fun `observe draft state returns it when existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expected = DraftStateSample.NewDraftState + expectDraftStateLocalDataSourceSuccess(userId, draftId, expected) + + // When + val actual = repository.observe(userId, draftId).first() + + // Then + assertEquals(expected.right(), actual) + } + + @Test + fun `observe all draft state returns them when existing`() = runTest { + // Given + val expected = listOf( + DraftStateSample.RemoteDraftInSendingState, + DraftStateSample.RemoteDraftInErrorSendingState, + DraftStateSample.RemoteDraftInSentState + ) + expectAllDraftStateLocalDataSourceSuccess(userId, expected) + + // When + val actual = repository.observeAll(userId).first() + + // Then + assertEquals(expected, actual) + } + + @Test + fun `observe all draft state returns empty list when not existing`() = runTest { + // Given + val expected = emptyList() + expectAllDraftStateLocalDataSourceNoDataAvailable(userId) + + // When + val actual = repository.observeAll(userId).first() + + // Then + assertEquals(expected, actual) + } + + @Test + fun `observe draft state returns no data cached error when not existing`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expectedError = DataError.Local.NoDataCached + expectDraftStateLocalDataSourceFailure(userId, draftId, expectedError) + + // When + val actual = repository.observe(userId, draftId).first() + + // Then + assertEquals(expectedError.left(), actual) + } + + @Test + fun `store synced state does update api message id and draft sync state`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val remoteDraftId = MessageIdSample.RemoteDraft + val existingState = DraftStateSample.NewDraftState + val expectedDraftState = existingState.copy( + apiMessageId = remoteDraftId, + state = DraftSyncState.Synchronized + ) + expectDraftStateLocalDataSourceSuccess(userId, draftId, existingState) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.updateApiMessageIdAndSetSyncedState(userId, draftId, remoteDraftId) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should block updating draft sync state of outbox items when in sending state`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val remoteDraftId = MessageIdSample.RemoteDraft + val existingState = DraftStateSample.RemoteDraftInSendingState + val expectedDraftState = existingState.copy( + apiMessageId = remoteDraftId, + state = DraftSyncState.Sending + ) + expectDraftStateLocalDataSourceSuccess(userId, draftId, existingState) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.updateApiMessageIdAndSetSyncedState(userId, draftId, remoteDraftId) + + // Then + assertEquals(Unit.right(), actual) + coVerify { draftStateLocalDataSource.save(expectedDraftState) } + } + + @Test + fun `should block updating draft sync state of outbox items when in sent state`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val remoteDraftId = MessageIdSample.RemoteDraft + val existingState = DraftStateSample.RemoteDraftInSentState + val expectedDraftState = existingState.copy( + apiMessageId = remoteDraftId, + state = DraftSyncState.Sent + ) + expectDraftStateLocalDataSourceSuccess(userId, draftId, existingState) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.updateApiMessageIdAndSetSyncedState(userId, draftId, remoteDraftId) + + // Then + assertEquals(Unit.right(), actual) + coVerify { draftStateLocalDataSource.save(expectedDraftState) } + } + + @Test + fun `save local state stores new draft state when no state exists`() = runTest { + // Given + val draftId = MessageIdSample.EmptyDraft + val expectedDraftState = DraftStateSample.NewDraftState.copy( + messageId = draftId, + state = DraftSyncState.Local + ) + val expectedAction = DraftAction.Compose + val expectedError = DataError.Local.NoDataCached + expectDraftStateLocalDataSourceFailure(userId, draftId, expectedError) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.createOrUpdateLocalState(userId, draftId, expectedAction) + + // Then + coVerify { draftStateLocalDataSource.save(expectedDraftState) } + assertEquals(Unit.right(), actual) + } + + @Test + fun `save local state updates draft state in data source when state exists`() = runTest { + // Given + val draftId = MessageIdSample.RemoteDraft + val existingState = DraftStateSample.RemoteDraftState + val expectedAction = DraftAction.Compose + val expectedDraftState = existingState.copy(state = DraftSyncState.Local) + expectDraftStateLocalDataSourceSuccess(userId, draftId, existingState) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.createOrUpdateLocalState(userId, draftId, expectedAction) + + // Then + coVerify { draftStateLocalDataSource.save(expectedDraftState) } + assertEquals(Unit.right(), actual) + } + + @Test + fun `confirm sending status updates draft state in data source when state exists`() = runTest { + // Given + val draftId = MessageIdSample.RemoteDraft + val existingState = DraftStateSample.RemoteDraftInSentState + val expectedDraftState = existingState.copy(sendingStatusConfirmed = true) + expectDraftStateLocalDataSourceSuccess(userId, draftId, existingState) + expectLocalDataSourceUpsertSuccess(expectedDraftState) + + // When + val actual = repository.updateConfirmDraftSendingStatus(userId, draftId, true) + + // Then + coVerify { draftStateLocalDataSource.save(expectedDraftState) } + assertEquals(Unit.right(), actual) + } + + private fun expectDraftStateLocalDataSourceSuccess( + userId: UserId, + draftId: MessageId, + expected: DraftState + ) { + coEvery { draftStateLocalDataSource.observe(userId, draftId) } returns flowOf(expected.right()) + } + + private fun expectAllDraftStateLocalDataSourceSuccess(userId: UserId, expected: List) { + coEvery { draftStateLocalDataSource.observeAll(userId) } returns flowOf(expected) + } + + private fun expectLocalDataSourceUpsertSuccess(state: DraftState) { + coEvery { draftStateLocalDataSource.save(state) } returns Unit.right() + } + + private fun expectDraftStateLocalDataSourceFailure( + userId: UserId, + draftId: MessageId, + error: DataError + ) { + coEvery { draftStateLocalDataSource.observe(userId, draftId) } returns flowOf(error.left()) + } + + private fun expectAllDraftStateLocalDataSourceNoDataAvailable(userId: UserId) { + coEvery { draftStateLocalDataSource.observeAll(userId) } returns flowOf(emptyList()) + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImplTest.kt new file mode 100644 index 0000000000..4d2ca1bcf4 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessageExpirationTimeRepositoryImplTest.kt @@ -0,0 +1,65 @@ +package ch.protonmail.android.composer.data.repository + +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.composer.data.local.MessageExpirationTimeLocalDataSource +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days + +class MessageExpirationTimeRepositoryImplTest { + + val userId = UserIdTestData.userId + val messageId = MessageIdSample.NewDraftWithSubjectAndBody + + private val messageExpirationTimeLocalDataSource = mockk() + + private val messageExpirationTimeRepository = MessageExpirationTimeRepositoryImpl( + messageExpirationTimeLocalDataSource + ) + + @Test + fun `should call method from local data source when saving message password`() = runTest { + // Given + val expiresIn = 1.days + val messageExpirationTime = MessageExpirationTime(userId, messageId, expiresIn) + coEvery { messageExpirationTimeLocalDataSource.save(messageExpirationTime) } returns Unit.right() + + // When + val actual = messageExpirationTimeRepository.saveMessageExpirationTime(messageExpirationTime) + + // Then + coVerify { + messageExpirationTimeLocalDataSource.save(messageExpirationTime) + } + assertEquals(Unit.right(), actual) + } + + @Test + fun `should call method from local data source when observing message password`() = runTest { + // Given + val expiresIn = 1.days + val messageExpirationTime = MessageExpirationTime(userId, messageId, expiresIn) + coEvery { + messageExpirationTimeLocalDataSource.observe(userId, messageId) + } returns flowOf(messageExpirationTime) + + // When + messageExpirationTimeRepository.observeMessageExpirationTime(userId, messageId).test { + // Then + coVerify { + messageExpirationTimeLocalDataSource.observe(userId, messageId) + } + assertEquals(messageExpirationTime, awaitItem()) + awaitComplete() + } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImplTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImplTest.kt new file mode 100644 index 0000000000..95de5a5eea --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/repository/MessagePasswordRepositoryImplTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.repository + +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.composer.data.local.MessagePasswordLocalDataSource +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class MessagePasswordRepositoryImplTest { + + val userId = UserIdTestData.userId + val messageId = MessageIdSample.NewDraftWithSubjectAndBody + + private val messagePasswordLocalDataSource = mockk() + + private val messagePasswordRepository = MessagePasswordRepositoryImpl(messagePasswordLocalDataSource) + + @Test + fun `should call method from local data source when saving message password`() = runTest { + // Given + val password = "password" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, password, hint) + coEvery { messagePasswordLocalDataSource.save(messagePassword) } returns Unit.right() + + // When + val actual = messagePasswordRepository.saveMessagePassword(messagePassword) + + // Then + coVerify { + messagePasswordLocalDataSource.save(messagePassword) + } + assertEquals(Unit.right(), actual) + } + + @Test + fun `should call method from local data source when updating message password`() = runTest { + // Given + val password = "password" + val hint = "hint" + coEvery { messagePasswordLocalDataSource.update(userId, messageId, password, hint) } returns Unit.right() + + // When + val actual = messagePasswordRepository.updateMessagePassword(userId, messageId, password, hint) + + // Then + coVerify { + messagePasswordLocalDataSource.update(userId, messageId, password, hint) + } + assertEquals(Unit.right(), actual) + } + + @Test + fun `should call method from local data source when observing message password`() = runTest { + // Given + val password = "password" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, password, hint) + coEvery { messagePasswordLocalDataSource.observe(userId, messageId) } returns flowOf(messagePassword) + + // When + messagePasswordRepository.observeMessagePassword(userId, messageId).test { + // Then + coVerify { + messagePasswordLocalDataSource.observe(userId, messageId) + } + assertEquals(messagePassword, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should call delete method from local data source when deleting message password`() = runTest { + // Given + coEvery { messagePasswordLocalDataSource.delete(userId, messageId) } just runs + + // When + messagePasswordRepository.deleteMessagePassword(userId, messageId) + + // Then + coVerify { messagePasswordLocalDataSource.delete(userId, messageId) } + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachmentTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachmentTest.kt new file mode 100644 index 0000000000..3abf4e7152 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/EncryptAndSignAttachmentTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.user.domain.entity.UserAddressKey +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +class EncryptAndSignAttachmentTest { + + @get:Rule + val loggingRule = LoggingTestRule() + + private val mockedSessionKey = SessionKey("mockedSessionKey".encodeToByteArray()) + private val mockedKeyPacket = "mockedKeyPacket".encodeToByteArray() + + private val armoredPrivateKey = "armoredPrivateKey" + private val armoredPublicKey = "armoredPublicKey" + private val encryptedPassphrase = EncryptedByteArray("encryptedPassphrase".encodeToByteArray()) + private val decryptedPassphrase = PlainByteArray("decryptedPassPhrase".encodeToByteArray()) + private val unlockedPrivateKey = "unlockedPrivateKey".encodeToByteArray() + + private val userAddressKey = mockk { + every { privateKey } returns PrivateKey( + key = armoredPrivateKey, + isPrimary = true, + isActive = true, + canEncrypt = true, + canVerify = true, + passphrase = encryptedPassphrase + ) + } + + private val userAddress = mockk { + every { keys } returns listOf(userAddressKey) + } + + private val pgpCryptoMock = mockk { + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + every { getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { generateNewSessionKey() } returns mockedSessionKey + every { encryptSessionKey(mockedSessionKey, armoredPublicKey) } returns mockedKeyPacket + every { decryptSessionKey(mockedKeyPacket, unlockedPrivateKey) } returns mockedSessionKey + } + private val cryptoContextMock = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { keyStoreCrypto } returns mockk { + every { decrypt(encryptedPassphrase) } returns decryptedPassphrase + } + } + + val encryptAndSignAttachment by lazy { EncryptAndSignAttachment(cryptoContextMock) } + + @Test + fun `when encrypting and signing attachment is successful EncryptedAttachmentResult is returned`() = runTest { + // Given + val decryptedFile = File.createTempFile("decrypted", "file") + val encryptedFile = File.createTempFile("encrypted", "file") + val unArmoredSignature = "unArmoredSignature".encodeToByteArray() + expectEncryptingFileSucceeds(encryptedFile) + expectSigningFileSucceeds(decryptedFile, unArmoredSignature) + + + val expected = EncryptedAttachmentResult( + keyPacket = mockedKeyPacket, + encryptedAttachment = encryptedFile, + signature = unArmoredSignature + ) + + // When + val actual = encryptAndSignAttachment(userAddress, decryptedFile) + + // Then + assertEquals(expected.right(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `when encrypting and signing of attachment fails to generate session key then FailedToGenerateSessionKey is returned`() = + runTest { + // Given + val exception = Exception("Failed to generate session key") + every { pgpCryptoMock.generateNewSessionKey() } throws exception + val expected = AttachmentEncryptionError.FailedToGenerateSessionKey(exception) + + // When + val actual = encryptAndSignAttachment(userAddress, File.createTempFile("decrypted", "file")) + + // Then + assertEquals(expected.left(), actual) + } + + @Test + fun `when encrypting and signing fails to encrypt session key then FailedToEncryptSessionKey is returned`() = + runTest { + // Given + val exception = CryptoException("Failed to encrypt session key") + expectEncryptingSessionKeyFails(exception) + val expected = AttachmentEncryptionError.FailedToEncryptSessionKey(exception) + + // When + val actual = encryptAndSignAttachment(userAddress, File.createTempFile("decrypted", "file")) + + // Then + assertEquals(expected.left(), actual) + } + + @Test + fun `when encrypting of attachment fails due then FailedToEncryptAttachment is returned`() = runTest { + // Given + val exception = CryptoException("Failed to encrypt attachment") + val expected = AttachmentEncryptionError.FailedToEncryptAttachment(exception) + + expectEncryptingFileFails(exception) + + // When + val actual = encryptAndSignAttachment(userAddress, File.createTempFile("decrypted", "file")) + + // Then + assertEquals(expected.left(), actual) + } + + @Test + fun `when signing of attachment fails then AttachmentEncryptionError_FailedToSignAttachment is returned`() = + runTest { + // Given + val decryptedFile = File.createTempFile("encrypted", "file") + val exception = CryptoException("Failed to sign attachment") + + val expected = AttachmentEncryptionError.FailedToSignAttachment(exception) + + expectEncryptingFileSucceeds(decryptedFile) + expectSigningFails(decryptedFile, exception) + + // When + val actual = encryptAndSignAttachment(userAddress, decryptedFile) + + // Then + assertEquals(expected.left(), actual) + } + + private fun expectEncryptingFileSucceeds(encryptedFile: File) { + every { pgpCryptoMock.encryptFile(any(), any(), mockedSessionKey) } returns encryptedFile + } + + private fun expectSigningFileSucceeds(decryptedFile: File, unArmoredSignature: ByteArray) { + val signature = "signature" + every { pgpCryptoMock.signFile(decryptedFile, unlockedPrivateKey) } returns signature + every { pgpCryptoMock.getUnarmored(signature) } returns unArmoredSignature + } + + private fun expectEncryptingSessionKeyFails(exception: Exception) { + every { pgpCryptoMock.encryptSessionKey(mockedSessionKey, armoredPublicKey) } throws exception + } + + private fun expectEncryptingFileFails(exception: CryptoException) { + every { pgpCryptoMock.encryptFile(any(), any(), mockedSessionKey) } throws exception + } + + private fun expectSigningFails(decryptedFile: File, exception: CryptoException) { + every { pgpCryptoMock.signFile(decryptedFile, unlockedPrivateKey) } throws exception + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackagesTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackagesTest.kt new file mode 100644 index 0000000000..29db6cc147 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMessagePackagesTest.kt @@ -0,0 +1,414 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.extension.encryptAndSignText +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.composer.data.sample.SendMessageSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.EncryptedMessage +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.pgp.dataPacket +import me.proton.core.crypto.common.pgp.decryptSessionKeyOrNull +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.crypto.common.pgp.keyPacket +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.key.domain.entity.key.PrivateKeyRing +import me.proton.core.key.domain.entity.key.PublicKeyRing +import me.proton.core.key.domain.entity.keyholder.KeyHolderContext +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.user.domain.entity.UserAddressKey +import me.proton.core.util.kotlin.toInt +import org.junit.Rule +import org.junit.Test +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class GenerateMessagePackagesTest { + + @get:Rule + val loggingRule = LoggingTestRule() + + private val attachmentKeyPackets = Base64.encode("keyPackets".toByteArray()) + private val attachment = MessageAttachmentSample.document.copy(keyPackets = attachmentKeyPackets) + private val attachmentFile = File.createTempFile("file", "txt") + private val draft = MessageWithBodySample.RemoteDraftWith4RecipientTypes.copy( + messageBody = MessageWithBodySample.RemoteDraftWith4RecipientTypes.messageBody.copy( + attachments = listOf(attachment) + ) + ) + + private val generateSendMessagePackagesMock = mockk() + private val generateMimeBodyMock = mockk() + + private val armoredPrivateKey = "armoredPrivateKey" + private val armoredPublicKey = "armoredPublicKey" + private val encryptedPassphrase = EncryptedByteArray("encryptedPassphrase".encodeToByteArray()) + private val decryptedPassphrase = PlainByteArray("decryptedPassPhrase".encodeToByteArray()) + private val unlockedPrivateKey = "unlockedPrivateKey".encodeToByteArray() + + private val userAddressKey = mockk { + every { privateKey } returns PrivateKey( + key = armoredPrivateKey, + isPrimary = true, + isActive = true, + canEncrypt = true, + canVerify = true, + passphrase = encryptedPassphrase + ) + } + + private val userAddress = mockk { + every { keys } returns listOf(userAddressKey) + } + + private val pgpCryptoMock = mockk { + every { getEncryptedPackets(draft.messageBody.body) } returns SendMessageSample.EncryptedPlaintextBodySplit + every { + getEncryptedPackets(SendMessageSample.PlaintextMimeBodyEncryptedAndSigned) + } returns SendMessageSample.PlaintextMimeBodyEncryptedAndSignedSplit + + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + every { getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { getBase64Decoded("keyPackets") } returns "keyPackets".encodeToByteArray() + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + every { + decryptSessionKey(Base64.decode(attachmentKeyPackets), unlockedPrivateKey) + } returns SendMessageSample.AttachmentSessionKey + every { + decryptSessionKeyOrNull(SendMessageSample.EncryptedPlaintextBodySplit.keyPacket(), unlockedPrivateKey) + } returns SendMessageSample.BodySessionKey + every { + decryptSessionKeyOrNull( + SendMessageSample.PlaintextMimeBodyEncryptedAndSignedSplit.keyPacket(), + unlockedPrivateKey + ) + } returns SendMessageSample.MimeBodySessionKey + every { decryptText(draft.messageBody.body, unlockedPrivateKey) } returns SendMessageSample.CleartextBody + /* we pass any() here because we don't want to mock result of generateMimeBody() */ + every { + encryptAndSignText(any(), armoredPublicKey, unlockedPrivateKey) + } returns SendMessageSample.PlaintextMimeBodyEncryptedAndSigned + every { + encryptAndSignText( + any(), + SendMessageSample.SendPreferences.PgpMime.publicKey!!.key, + unlockedPrivateKey + ) + } returns SendMessageSample.PlaintextMimeBodyEncryptedAndSigned + } + + private val cryptoContextMock = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { keyStoreCrypto } returns mockk { + every { decrypt(encryptedPassphrase) } returns decryptedPassphrase + } + } + + private val sut = GenerateMessagePackages( + cryptoContextMock, + generateSendMessagePackagesMock, + generateMimeBodyMock + ) + + @Test + fun `generates message packages for all provided SendPreferences`() = runTest { + // Given + val recipient1 = draft.message.toList.first().address + val recipient2 = draft.message.ccList.first().address + val recipient3 = draft.message.bccList.first().address + val recipient4 = draft.message.bccList[1].address + + val sendPreferences = mapOf( + recipient1 to SendMessageSample.SendPreferences.ProtonMail, + recipient2 to SendMessageSample.SendPreferences.ClearMime, + recipient3 to SendMessageSample.SendPreferences.Cleartext, + recipient4 to SendMessageSample.SendPreferences.PgpMime + ) + + givenAllRecipients(recipient1, recipient2, recipient3, recipient4) + + expectGenerateMimeBody( + SendMessageSample.CleartextBody, + MimeType.PlainText, + listOf(attachment), + mapOf(attachment.attachmentId to attachmentFile), + "generated MIME body" + ) + + // When + val actual = sut( + userAddress, + draft, + sendPreferences, + mapOf(attachment.attachmentId to attachmentFile), + SendMessageSample.MessagePassword, + SendMessageSample.Modulus + ) + + // Then + val emailsWithGeneratedPackages = actual.getOrNull()?.flatMap { + it.addresses.keys + } + + assertTrue( + emailsWithGeneratedPackages!!.containsAll( + listOf( + recipient1, + recipient2, + recipient3, + recipient4 + ) + ) + ) + + } + + @Test + fun `returns error when CryptoException is thrown`() = runTest { + // Given + val recipient1 = draft.message.toList.first().address + val recipient2 = draft.message.ccList.first().address + val recipient3 = draft.message.bccList.first().address + val recipient4 = draft.message.bccList[1].address + + val sendPreferences = mapOf( + recipient1 to SendMessageSample.SendPreferences.ProtonMail, + recipient2 to SendMessageSample.SendPreferences.ClearMime, + recipient3 to SendMessageSample.SendPreferences.Cleartext, + recipient4 to SendMessageSample.SendPreferences.PgpMime + ) + + givenAllRecipients(recipient1, recipient2, recipient3, recipient4) + + every { pgpCryptoMock.decryptText(any(), any()) } throws CryptoException("crypto failed") + + // When + val actual = sut( + userAddress, + draft, + sendPreferences, + mapOf(attachment.attachmentId to attachmentFile), + SendMessageSample.MessagePassword, + SendMessageSample.Modulus + ) + + // Then + assertTrue(actual.isLeft()) + + } + + @Test + fun `returns error when GenerateSendMessagePackages returns error`() = runTest { + // Given + val recipient1 = draft.message.toList.first().address + val recipient2 = draft.message.ccList.first().address + val recipient3 = draft.message.bccList.first().address + val recipient4 = draft.message.bccList[1].address + + val sendPreferences = mapOf( + recipient1 to SendMessageSample.SendPreferences.ProtonMail, + recipient2 to SendMessageSample.SendPreferences.ClearMime, + recipient3 to SendMessageSample.SendPreferences.Cleartext, + recipient4 to SendMessageSample.SendPreferences.PgpMime + ) + + givenAllRecipients(recipient1, recipient2, recipient3, recipient4) + + expectGenerateMimeBody( + SendMessageSample.CleartextBody, + MimeType.PlainText, + listOf(attachment), + mapOf(attachment.attachmentId to attachmentFile), + "generated MIME body" + ) + + coEvery { + generateSendMessagePackagesMock.invoke( + any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any() + ) + } returns GenerateSendMessagePackages.Error.ProtonMailAndCleartext("error").left() + + // When + val actual = sut( + userAddress, + draft, + sendPreferences, + mapOf(attachment.attachmentId to attachmentFile), + SendMessageSample.MessagePassword, + SendMessageSample.Modulus + ) + + // Then + assertTrue(actual.isLeft()) + + } + + /** + * Returns expected list of top-level Mail Packages for all recipients combined. + */ + private fun givenAllRecipients( + recipient1: String, + recipient2: String, + recipient3: String, + recipient4: String + ) { + coEvery { + generateSendMessagePackagesMock.invoke( + mapOf( + recipient1 to SendMessageSample.SendPreferences.ProtonMail, + recipient2 to SendMessageSample.SendPreferences.ClearMime, + recipient3 to SendMessageSample.SendPreferences.Cleartext, + recipient4 to SendMessageSample.SendPreferences.PgpMime + ), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedPlaintextBodySplit.dataPacket(), + SendMessageSample.MimeBodySessionKey, + SendMessageSample.PlaintextMimeBodyEncryptedAndSignedSplit.dataPacket(), + MimeType.PlainText, + mapOf( + recipient4 to Pair( + SendMessageSample.PlaintextMimeBodyEncryptedAndSignedSplit.keyPacket(), + SendMessageSample.PlaintextMimeBodyEncryptedAndSignedSplit.dataPacket() + ) + ), + mapOf(MessageAttachmentSample.document.attachmentId.id to SendMessageSample.AttachmentSessionKey), + areAllAttachmentsSigned = false, + messagePassword = SendMessageSample.MessagePassword, + modulus = SendMessageSample.Modulus + ) + } returns listOf( + SendMessagePackage( + addresses = mapOf( + recipient1 to SendMessagePackage.Address.Internal( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = emptyMap() + ), + recipient3 to SendMessagePackage.Address.ExternalCleartext( + signature = false.toInt() + ) + ), + mimeType = draft.messageBody.mimeType.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.ProtonMail.type + PackageType.Cleartext.type + ), + SendMessagePackage( + addresses = mapOf( + recipient2 to SendMessagePackage.Address.ExternalSigned( + signature = true.toInt() + ) + ), + mimeType = MimeType.MultipartMixed.value, + body = Base64.encode(SendMessageSample.EncryptedMimeBodyDataPacket), + type = PackageType.ClearMime.type + ), + SendMessagePackage( + addresses = mapOf( + recipient4 to SendMessagePackage.Address.ExternalEncrypted( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.EncryptedMimeBodyDataPacket) + ) + ), + mimeType = MimeType.MultipartMixed.value, + body = Base64.encode(SendMessageSample.EncryptedMimeBodyDataPacket), + type = PackageType.PgpMime.type + ) + ).right() + } + + @Test + fun `extension method KeyHolderContext#encryptAndSignText does not lock PrivateKey after using it`() = runTest { + // Given + val textToEncrypt = "text to encrypt" + + val privateKeyRingMock = PrivateKeyRing(cryptoContextMock, listOf(SendMessageSample.PrivateKey)) + val publicKeyRingMock = PublicKeyRing(listOf(SendMessageSample.PublicKey)) + + val privateKeyDecryptedPassphrase = PlainByteArray("decrypted private key passphrase".encodeToByteArray()) + val unlockedPrivateKey = "unlocked PrivateKey".encodeToByteArray() + + every { + cryptoContextMock.keyStoreCrypto.decrypt(SendMessageSample.PrivateKey.passphrase!!) + } returns privateKeyDecryptedPassphrase + + every { + pgpCryptoMock.unlock(SendMessageSample.PrivateKey.key, privateKeyDecryptedPassphrase.array) + } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + + every { + pgpCryptoMock.encryptAndSignText(textToEncrypt, any(), unlockedPrivateKey) + } returns EncryptedMessage() + + val sut = KeyHolderContext( + cryptoContextMock, + privateKeyRingMock, + publicKeyRingMock + ) + + // When + sut.encryptAndSignText("text to encrypt", SendMessageSample.PublicKey) + + verify(exactly = 0) { sut.privateKeyRing.unlockedPrimaryKey.unlockedKey.close() } + } + + private fun expectGenerateMimeBody( + body: String, + bodyContentType: MimeType, + attachments: List, + attachmentFiles: Map, + generatedMimeBody: String + ) { + every { + generateMimeBodyMock( + body, + bodyContentType, + attachments, + attachmentFiles + ) + } returns generatedMimeBody + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBodyTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBodyTest.kt new file mode 100644 index 0000000000..e8da76433a --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateMimeBodyTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import java.io.StringWriter +import ch.protonmail.android.composer.data.sample.SendMessageSample +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import com.github.mangstadt.vinnie.io.FoldedLineWriter +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.internal.util.io.IOUtil.writeText +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@Suppress("MaxLineLength") +@OptIn(ExperimentalEncodingApi::class) +class GenerateMimeBodyTest { + + private val sut = GenerateMimeBody() + + @Test + fun `generates MIME attachment part with Base64 encoded file name`() = runTest { + + // Given + val expectedAttachment = MessageAttachmentSample.document + val expectedFileText = "expected content of a text file" + val expectedAttachmentFile = expectFileWithText(expectedFileText) + + val expectedBase64FileName = Base64.encode(expectedAttachment.name.toByteArray()) + + val expectedFileNameTemplate = "=?UTF-8?B?$expectedBase64FileName?=" + + val stringWriter = StringWriter() + FoldedLineWriter(stringWriter).use { + it.write("Content-Transfer-Encoding: base64") + it.writeln() + it.write("Content-Type: ${expectedAttachment.mimeType}; filename=\"$expectedFileNameTemplate\"; name=\"$expectedFileNameTemplate\"") + it.writeln() + it.write("Content-Disposition: attachment; filename=\"$expectedFileNameTemplate\"; name=\"$expectedFileNameTemplate\"") + it.writeln() + it.writeln() + it.write(Base64.encode(expectedAttachmentFile.readBytes())) + } + // FoldedLineWriter uses CRLF + val expectedQuotedPrintableAttachmentPart = stringWriter.toString().replace("\r\n", "\n") + + // When + val actual = sut( + SendMessageSample.CleartextBody, + MimeType.PlainText, + listOf(expectedAttachment), + mapOf(expectedAttachment.attachmentId to expectedAttachmentFile) + ) + + // Then + assert(actual.contains(expectedQuotedPrintableAttachmentPart)) + } + + private fun expectFileWithText(text: String): File = File.createTempFile("file", "txt").also { + writeText(text, it) + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackagesTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackagesTest.kt new file mode 100644 index 0000000000..4806b403c6 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GenerateSendMessagePackagesTest.kt @@ -0,0 +1,706 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.composer.data.sample.SendMessageSample +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.srp.Auth +import me.proton.core.crypto.common.srp.SrpCrypto +import me.proton.core.key.domain.encryptSessionKey +import me.proton.core.key.domain.entity.key.PublicKey +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.util.kotlin.toInt +import org.junit.Assert.assertEquals +import org.junit.Rule +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class GenerateSendMessagePackagesTest { + + @get:Rule + val loggingRule = LoggingTestRule() + + private val pgpCryptoMock = mockk() + private val srpCryptoMock = mockk() + private val cryptoContextMock = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { srpCrypto } returns srpCryptoMock + } + + private val sut = GenerateSendMessagePackages(cryptoContextMock) + + @Test + fun `using invalid SendPreferences without PublicKey returns empty list`() = runTest { + // Given + val sendPreferencesProtonMailButNoPublicKey = SendMessageSample.SendPreferences.ProtonMailWithEmptyPublicKey + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferencesProtonMailButNoPublicKey), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + assertEquals(emptyList(), actual) + loggingRule.assertErrorLogged( + "GenerateSendMessagePackages: publicKey for" + + " ${sendPreferencesProtonMailButNoPublicKey.pgpScheme.name} was null" + ) + } + + @Test + fun `providing no signedEncryptedMimeBody for PgpMime returns empty list`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.PgpMime + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + emptyMap(), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + assertEquals(emptyList(), actual) + loggingRule.assertErrorLogged("GenerateSendMessagePackages: signedEncryptedMimeBody was null") + } + + @Test + fun `generate package for ProtonMail, no attachments`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.ProtonMail.copy( + publicKey = expectPublicKeyEncryptSessionKey() + ) + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.Internal( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = emptyMap() + ) + ), + mimeType = sendPreferences.mimeType.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.ProtonMail.type + ) + + assertNotNull(actual) + assertEquals(listOf(expected), actual) + + // make sure we don't leak keys, because everything should be encrypted + assertEquals(null, actual.first().attachmentKeys) + assertEquals(null, actual.first().bodyKey) + } + + @Test + fun `generate package for PgpMime, no attachments`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.PgpMime + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.ExternalEncrypted( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.SignedEncryptedMimeBody.first) + ) + ), + mimeType = me.proton.core.mailsettings.domain.entity.MimeType.Mixed.value, // forced multipart + body = Base64.encode(SendMessageSample.SignedEncryptedMimeBody.second), + type = PackageType.PgpMime.type + ) + + assertNotNull(actual) + assertEquals(listOf(expected), actual) + + // make sure we don't leak keys, because everything should be encrypted + assertEquals(null, actual.first().attachmentKeys) + assertEquals(null, actual.first().bodyKey) + } + + @Test + fun `generate package for ClearMime, no attachments`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.ClearMime + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.ExternalSigned( + signature = true.toInt() + ) + ), + mimeType = me.proton.core.mailsettings.domain.entity.MimeType.Mixed.value, // forced multipart + body = Base64.encode(SendMessageSample.EncryptedMimeBodyDataPacket), + type = PackageType.ClearMime.type, + bodyKey = SendMessageSample.CleartextMimeBodyKey + ) + + assertEquals(listOf(expected), actual) + } + + @Test + fun `generate package for Cleartext, no attachments`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.Cleartext + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.ExternalCleartext( + signature = false.toInt() + ) + ), + mimeType = MimeType.PlainText.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.Cleartext.type, + bodyKey = SendMessageSample.CleartextBodyKey, + attachmentKeys = emptyMap() + ) + + assertEquals(listOf(expected), actual) + } + + @Test + fun `generate package for Cleartext, no attachments, override SendPreferences body MimeType with HTML`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.Cleartext + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.Html, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.ExternalCleartext( + signature = false.toInt() + ) + ), + mimeType = MimeType.Html.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.Cleartext.type, + bodyKey = SendMessageSample.CleartextBodyKey, + attachmentKeys = emptyMap() + ) + + assertEquals(listOf(expected), actual) + } + + @Test + fun `generate package for ProtonMail with 'sign' disabled when there are unsigned attachments`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.ProtonMail.copy( + publicKey = expectPublicKeyEncryptSessionKey() + ) + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = false, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.Internal( + signature = false.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = emptyMap() + ) + ), + mimeType = sendPreferences.mimeType.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.ProtonMail.type + ) + + assertEquals(listOf(expected), actual) + } + + @Test + fun `generate packages for Internal`() = runTest { + // Given + val sendPreferencesInternal = SendMessageSample.SendPreferences.ProtonMail.copy( + publicKey = expectPublicKeyEncryptSessionKey() + ) + + // When + val actual = sut( + mapOf( + SendMessageSample.RecipientEmail to sendPreferencesInternal + ), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + emptyMap(), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + + assertNotNull(actual) + assertTrue(actual.size == 1) + assertTrue(actual.first().addresses.size == 1) + assertTrue { + actual.first() + .addresses[SendMessageSample.RecipientEmail] is SendMessagePackage.Address.Internal + } + assertEquals(actual.first().type, PackageType.ProtonMail.type) + } + + @Test + fun `generate packages for Cleartext`() = runTest { + // Given + val sendPreferencesCleartext = SendMessageSample.SendPreferences.Cleartext + + // When + val actual = sut( + mapOf( + SendMessageSample.RecipientAliasEmail to sendPreferencesCleartext + ), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + emptyMap(), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + + assertNotNull(actual) + assertTrue(actual.size == 1) + assertTrue(actual.first().addresses.size == 1) + assertTrue { + actual.first() + .addresses[SendMessageSample.RecipientAliasEmail] is SendMessagePackage.Address.ExternalCleartext + } + assertEquals(actual.first().type, PackageType.Cleartext.type) + } + + @Test + fun `generate packages for ExternalSigned creates 1 shared package`() = runTest { + + // Given + val sendPreferences = SendMessageSample.SendPreferences.ClearMime + + // When + val actual = sut( + mapOf( + SendMessageSample.RecipientEmail to sendPreferences, + SendMessageSample.RecipientAliasEmail to sendPreferences + ), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + emptyMap(), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + + assertNotNull(actual) + assertTrue(actual.size == 1) + assertTrue(actual.first().addresses.size == 2) + assertTrue { + actual.first() + .addresses[SendMessageSample.RecipientEmail] is SendMessagePackage.Address.ExternalSigned + } + assertTrue { + actual.first() + .addresses[SendMessageSample.RecipientAliasEmail] is SendMessagePackage.Address.ExternalSigned + } + kotlin.test.assertEquals(actual.first().type, PackageType.ClearMime.type) + } + + @Test + fun `generate package for ProtonMail always uses PlainText MimeType when body is plain text`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.ProtonMailWithHtmlMime.copy( + publicKey = expectPublicKeyEncryptSessionKey() + ) + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.Internal( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = emptyMap() + ) + ), + mimeType = MimeType.PlainText.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.ProtonMail.type + ) + + assertNotNull(actual) + assertEquals(listOf(expected), actual) + + // make sure we don't leak keys, because everything should be encrypted + assertEquals(null, actual.first().attachmentKeys) + assertEquals(null, actual.first().bodyKey) + } + + @Test + fun `generate package for EncryptedOutside`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.ClearMime + expectEncryptBodySessionKeyWithPassword() + expectEncryptAttachmentSessionKeyWithPassword() + expectGenerateRandomBytes() + expectEncryptTextWithPassword() + expectCalculatePasswordVerifier() + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + mapOf(SendMessageSample.AttachmentId to SendMessageSample.AttachmentSessionKey), + areAllAttachmentsSigned = true, + messagePassword = SendMessageSample.MessagePassword, + modulus = SendMessageSample.Modulus + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.EncryptedOutside( + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = mapOf( + SendMessageSample.AttachmentId to Base64.encode(SendMessageSample.EncryptedAttachmentSessionKey) + ), + token = SendMessageSample.Token, + encToken = SendMessageSample.EncryptedToken, + auth = SendMessageSample.Auth, + passwordHint = SendMessageSample.MessagePassword.passwordHint, + signature = true.toInt() + ) + ), + mimeType = MimeType.PlainText.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = 2 + ) + + // Then + assertNotNull(actual) + assertEquals(listOf(expected), actual) + // make sure we don't leak keys, because everything should be encrypted + assertEquals(null, actual.first().attachmentKeys) + assertEquals(null, actual.first().bodyKey) + } + + @Test + fun `generate a package for EncryptedOutside when pgp scheme is PGP MIME but encrypt setting is false`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.PgpMimeEncryptFalse + expectEncryptBodySessionKeyWithPassword() + expectEncryptAttachmentSessionKeyWithPassword() + expectGenerateRandomBytes() + expectEncryptTextWithPassword() + expectCalculatePasswordVerifier() + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + mapOf(SendMessageSample.AttachmentId to SendMessageSample.AttachmentSessionKey), + areAllAttachmentsSigned = true, + messagePassword = SendMessageSample.MessagePassword, + modulus = SendMessageSample.Modulus + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.EncryptedOutside( + bodyKeyPacket = Base64.encode(SendMessageSample.RecipientBodyKeyPacket), + attachmentKeyPackets = mapOf( + SendMessageSample.AttachmentId to Base64.encode(SendMessageSample.EncryptedAttachmentSessionKey) + ), + token = SendMessageSample.Token, + encToken = SendMessageSample.EncryptedToken, + auth = SendMessageSample.Auth, + passwordHint = SendMessageSample.MessagePassword.passwordHint, + signature = true.toInt() + ) + ), + mimeType = MimeType.PlainText.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = 2 + ) + + // Then + assertNotNull(actual) + assertEquals(listOf(expected), actual) + // make sure we don't leak keys, because everything should be encrypted + assertEquals(null, actual.first().attachmentKeys) + assertEquals(null, actual.first().bodyKey) + } + + @Test + fun `fallback to PgpMime when contact send preferences is pgpInline`() = runTest { + // Given + val sendPreferences = SendMessageSample.SendPreferences.PgpInline + + // When + val actual = sut( + mapOf(SendMessageSample.RecipientEmail to sendPreferences), + SendMessageSample.BodySessionKey, + SendMessageSample.EncryptedBodyDataPacket, + SendMessageSample.MimeBodySessionKey, + SendMessageSample.EncryptedMimeBodyDataPacket, + MimeType.PlainText, + mapOf(SendMessageSample.RecipientEmail to SendMessageSample.SignedEncryptedMimeBody), + emptyMap(), + areAllAttachmentsSigned = true, + messagePassword = null, + modulus = null + ).getOrNull() + + // Then + val expected = SendMessagePackage( + addresses = mapOf( + SendMessageSample.RecipientEmail to SendMessagePackage.Address.ExternalEncrypted( + signature = true.toInt(), + bodyKeyPacket = Base64.encode(SendMessageSample.SignedEncryptedMimeBody.first) + ) + ), + mimeType = me.proton.core.mailsettings.domain.entity.MimeType.Mixed.value, // forced multipart + body = Base64.encode(SendMessageSample.SignedEncryptedMimeBody.second), + type = PackageType.PgpMime.type + ) + + assertNotNull(actual) + assertEquals(listOf(expected), actual) + + // make sure we don't leak keys, because everything should be encrypted + assertNull(actual.first().attachmentKeys) + assertNull(actual.first().bodyKey) + } + + private fun expectPublicKeyEncryptSessionKey(): PublicKey { + mockkStatic(PublicKey::encryptSessionKey) + return mockk { + every { + encryptSessionKey(cryptoContextMock, SendMessageSample.BodySessionKey) + } returns SendMessageSample.RecipientBodyKeyPacket + } + } + + private fun expectEncryptBodySessionKeyWithPassword() { + every { + pgpCryptoMock.encryptSessionKeyWithPassword( + SendMessageSample.BodySessionKey, + SendMessageSample.PasswordByteArray + ) + } returns SendMessageSample.RecipientBodyKeyPacket + } + + private fun expectEncryptAttachmentSessionKeyWithPassword() { + every { + pgpCryptoMock.encryptSessionKeyWithPassword( + SendMessageSample.AttachmentSessionKey, + SendMessageSample.PasswordByteArray + ) + } returns SendMessageSample.EncryptedAttachmentSessionKey + } + + private fun expectGenerateRandomBytes() { + every { pgpCryptoMock.generateRandomBytes(size = 32) } returns SendMessageSample.TokenByteArray + } + + private fun expectEncryptTextWithPassword() { + every { + pgpCryptoMock.encryptTextWithPassword( + SendMessageSample.Token, + SendMessageSample.PasswordByteArray + ) + } returns SendMessageSample.EncryptedToken + } + + private fun expectCalculatePasswordVerifier() { + coEvery { + srpCryptoMock.calculatePasswordVerifier( + "", + SendMessageSample.PasswordByteArray, + SendMessageSample.Modulus.modulusId, + SendMessageSample.Modulus.modulus + ) + } returns Auth( + version = SendMessageSample.Auth.version, + modulusId = SendMessageSample.Auth.modulusId, + salt = SendMessageSample.Auth.salt, + verifier = SendMessageSample.Auth.verifier + ) + } + +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFilesTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFilesTest.kt new file mode 100644 index 0000000000..401ca5b6b5 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/GetAttachmentFilesTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.ApiMessageId +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.Attachment1 +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.Attachment2 +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.AttachmentId1 +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.AttachmentId2 +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.AttachmentIds +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.DraftState +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.MessageId +import ch.protonmail.android.composer.data.usecase.GetAttachmentFilesTest.TestData.UserId +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.data.local.usecase.AttachmentDecryptionError +import ch.protonmail.android.mailmessage.data.local.usecase.DecryptAttachmentByteArray +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetAttachmentFilesTest { + + private val attachmentRepository = mockk { + coEvery { readFileFromStorage(UserId, ApiMessageId, AttachmentId1) } returns Attachment1.right() + } + private val decryptAttachmentByteArray = mockk() + private val draftStateRepository = mockk { + coEvery { observe(UserId, MessageId) } returns flowOf(DraftState.right()) + } + + private val getAttachmentFiles = GetAttachmentFiles( + attachmentRepository = attachmentRepository, + decryptAttachmentByteArray = decryptAttachmentByteArray, + draftStateRepository = draftStateRepository + ) + + @Test + fun `should return files if all attachments were read successfully`() = runTest { + // Given + val expected = mapOf(AttachmentId1 to Attachment1, AttachmentId2 to Attachment2).right() + expectReadFileFromStorageSucceeds(AttachmentId2, Attachment2) + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `should return files when some attachments are not available locally and are fetched from api successfully`() = + runTest { + // Given + val encryptedAttachmentByteArray = "encryptedAttachmentByteArray".encodeToByteArray() + val decryptedAttachmentByteArray = "decryptedAttachmentByteArray".encodeToByteArray() + expectReadFileFromStorageFails(AttachmentId2) + expectGetAttachmentFromRemoteSucceeds(AttachmentId2, encryptedAttachmentByteArray) + expectDecryptAttachmentByteArraySucceeds( + AttachmentId2, encryptedAttachmentByteArray, decryptedAttachmentByteArray + ) + expectSaveAttachmentSucceeds(AttachmentId2, decryptedAttachmentByteArray, Attachment2) + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + val expected = mapOf(AttachmentId1 to Attachment1, AttachmentId2 to Attachment2).right() + assertEquals(expected, actual) + coVerifyOrder { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId1) + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId2) + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, AttachmentId2) + decryptAttachmentByteArray(UserId, ApiMessageId, AttachmentId2, encryptedAttachmentByteArray) + attachmentRepository.saveAttachmentToFile( + UserId, ApiMessageId, AttachmentId2, decryptedAttachmentByteArray + ) + } + } + + @Test + fun `should return error when some attachments are not available locally and fetching them from api has failed`() = + runTest { + // Given + expectReadFileFromStorageFails(AttachmentId2) + coEvery { + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, AttachmentId2) + } returns DataError.Remote.Unknown.left() + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + assertEquals(GetAttachmentFiles.Error.DownloadingAttachments.left(), actual) + coVerifyOrder { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId1) + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId2) + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, AttachmentId2) + } + } + + @Test + fun `should return error when attachments not available locally are fetched but the decryption failed`() = runTest { + // Given + val encryptedAttachmentByteArray = "encryptedAttachmentByteArray".encodeToByteArray() + expectReadFileFromStorageFails(AttachmentId2) + expectGetAttachmentFromRemoteSucceeds(AttachmentId2, encryptedAttachmentByteArray) + coEvery { + decryptAttachmentByteArray(UserId, ApiMessageId, AttachmentId2, encryptedAttachmentByteArray) + } returns AttachmentDecryptionError.DecryptionFailed.left() + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + assertEquals(GetAttachmentFiles.Error.DownloadingAttachments.left(), actual) + coVerifyOrder { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId1) + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId2) + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, AttachmentId2) + decryptAttachmentByteArray(UserId, ApiMessageId, AttachmentId2, encryptedAttachmentByteArray) + } + } + + @Test + fun `should return error when attachments not available locally are fetched and decrypted but saving failed`() = + runTest { + // Given + val encryptedAttachmentByteArray = "encryptedAttachmentByteArray".encodeToByteArray() + val decryptedAttachmentByteArray = "decryptedAttachmentByteArray".encodeToByteArray() + expectReadFileFromStorageFails(AttachmentId2) + expectGetAttachmentFromRemoteSucceeds(AttachmentId2, encryptedAttachmentByteArray) + expectDecryptAttachmentByteArraySucceeds( + AttachmentId2, encryptedAttachmentByteArray, decryptedAttachmentByteArray + ) + coEvery { + attachmentRepository.saveAttachmentToFile( + UserId, ApiMessageId, AttachmentId2, decryptedAttachmentByteArray + ) + } returns DataError.Local.FailedToStoreFile.left() + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + assertEquals(GetAttachmentFiles.Error.FailedToStoreFile.left(), actual) + coVerifyOrder { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId1) + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, AttachmentId2) + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, AttachmentId2) + decryptAttachmentByteArray(UserId, ApiMessageId, AttachmentId2, encryptedAttachmentByteArray) + attachmentRepository.saveAttachmentToFile( + UserId, ApiMessageId, AttachmentId2, decryptedAttachmentByteArray + ) + } + } + + @Test + fun `should return error if api assigned message id still doesn't exist`() = runTest { + // Given + val expected = GetAttachmentFiles.Error.DraftNotFound.left() + coEvery { draftStateRepository.observe(UserId, MessageId) } returns flowOf(DataError.Local.NoDataCached.left()) + + // When + val actual = getAttachmentFiles(UserId, MessageId, AttachmentIds) + + // Then + assertEquals(expected, actual) + } + + private fun expectReadFileFromStorageSucceeds(attachmentId: AttachmentId, attachment: File) { + coEvery { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, attachmentId) + } returns attachment.right() + } + + private fun expectReadFileFromStorageFails(attachmentId: AttachmentId) { + coEvery { + attachmentRepository.readFileFromStorage(UserId, ApiMessageId, attachmentId) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectGetAttachmentFromRemoteSucceeds( + attachmentId: AttachmentId, + encryptedAttachmentContent: ByteArray + ) { + coEvery { + attachmentRepository.getAttachmentFromRemote(UserId, ApiMessageId, attachmentId) + } returns encryptedAttachmentContent.right() + } + + private fun expectDecryptAttachmentByteArraySucceeds( + attachmentId: AttachmentId, + encryptedAttachmentContent: ByteArray, + decryptedAttachmentContent: ByteArray + ) { + coEvery { + decryptAttachmentByteArray(UserId, ApiMessageId, attachmentId, encryptedAttachmentContent) + } returns decryptedAttachmentContent.right() + } + + private fun expectSaveAttachmentSucceeds( + attachmentId: AttachmentId, + decryptedAttachmentContent: ByteArray, + attachment: File + ) { + coEvery { + attachmentRepository.saveAttachmentToFile(UserId, ApiMessageId, attachmentId, decryptedAttachmentContent) + } returns attachment.right() + } + + object TestData { + val UserId = UserIdTestData.userId + val MessageId = MessageIdSample.MessageWithAttachments + val ApiMessageId = MessageId("apiMessageId") + + val AttachmentId1 = AttachmentId("attachmentId1") + val AttachmentId2 = AttachmentId("attachmentId2") + val AttachmentIds = listOf(AttachmentId1, AttachmentId2) + + val Attachment1 = File.createTempFile("attachment1", "txt") + val Attachment2 = File.createTempFile("attachment2", "txt") + + val DraftState = DraftState( + UserId, + MessageId, + ApiMessageId, + DraftSyncState.Synchronized, + DraftAction.Compose, + null, + false + ) + } +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/SendMessageTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/SendMessageTest.kt new file mode 100644 index 0000000000..ee189e4d06 --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/SendMessageTest.kt @@ -0,0 +1,595 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.remote.MessageRemoteDataSource +import ch.protonmail.android.composer.data.remote.resource.SendMessageBody +import ch.protonmail.android.composer.data.remote.resource.SendMessagePackage +import ch.protonmail.android.composer.data.remote.response.SendMessageResponse +import ch.protonmail.android.composer.data.sample.SendMessageSample +import ch.protonmail.android.mailcommon.domain.mapper.fromProtonCode +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.sample.MessageSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailsettings.domain.usecase.ObserveMailSettings +import ch.protonmail.android.testdata.message.MessageBodyTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.account.domain.repository.AccountRepository +import me.proton.core.auth.domain.repository.AuthRepository +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.entity.key.PublicKey +import me.proton.core.mailsendpreferences.domain.model.SendPreferences +import me.proton.core.mailsendpreferences.domain.usecase.ObtainSendPreferences +import me.proton.core.mailsettings.domain.entity.MimeType +import me.proton.core.mailsettings.domain.entity.PackageType +import me.proton.core.user.domain.entity.AddressId +import me.proton.core.util.kotlin.toInt +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SendMessageTest { + + private val accountRepository = mockk() + private val authRepository = mockk() + private val messageRemoteDataSource = mockk() + private val resolveUserAddress = mockk() + private val generateMessagePackages = mockk() + private val findLocalDraft = mockk() + private val obtainSendPreferences = mockk() + private val observeMailSettings = mockk() + private val getAttachmentFiles = mockk() + private val observeMessagePassword = mockk() + private val observeMessageExpirationTime = mockk() + + private val sendMessage = SendMessage( + accountRepository, + authRepository, + messageRemoteDataSource, + resolveUserAddress, + generateMessagePackages, + findLocalDraft, + obtainSendPreferences, + observeMailSettings, + getAttachmentFiles, + observeMessagePassword, + observeMessageExpirationTime + ) + + private val userId = UserAddressSample.PrimaryAddress.userId + private val baseRecipient = Participant("address@proton.me", "name") + private val sampleMessage = generateDraftMessage(toRecipients = listOf(baseRecipient)) + private val messageId = sampleMessage.message.messageId + private val recipients = sampleMessage.message.run { toList + ccList + bccList }.map { it.address } + + @AfterTest + fun tearDown() { + unmockkAll() + } + + @Test + fun `when local draft is not found upon sending, the error is propagated to the calling site`() = runTest { + // Given + coEvery { findLocalDraft.invoke(userId, messageId) } returns null + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(SendMessage.Error.DraftNotFound.left(), result) + } + + @Test + fun `when send address cannot be resolved, the error is propagated to the calling site`() = runTest { + // Given + expectFindLocalDraftSucceeds() + coEvery { + resolveUserAddress( + userId, + sampleMessage.message.addressId + ) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(SendMessage.Error.SenderAddressNotFound.left(), result) + } + + @Test + fun `when send preferences cannot be fetched, the error is propagated to the calling site`() = runTest { + // Given + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + coEvery { + obtainSendPreferences(userId, recipients) + } returns mapOf(Pair(baseRecipient.address, ObtainSendPreferences.Result.Error.AddressDisabled)) + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals( + SendMessage.Error.SendPreferences( + mapOf( + baseRecipient.address to ObtainSendPreferences.Result.Error.AddressDisabled + ) + ).left(), + result + ) + } + + @Test + fun `when attachment reading fails, the error is propagated to the calling site`() = runTest { + // Given + val sendPreferences = generateSendPreferences(encrypt = true, sign = true, pgpScheme = PackageType.PgpMime) + val expectedError = SendMessage.Error.DownloadingAttachments.left() + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectObtainSendPreferencesSucceeds { sendPreferences } + coEvery { + getAttachmentFiles(userId, messageId, sampleMessage.messageBody.attachments.map { it.attachmentId }) + } returns GetAttachmentFiles.Error.DraftNotFound.left() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(expectedError, result) + } + + @Test + fun `should read attachments for PGP MIME messages`() = runTest { + // Given + val sendPreferences = generateSendPreferences(encrypt = true, sign = true, pgpScheme = PackageType.PgpMime) + val attachmentIds = sampleMessage.messageBody.attachments.map { it.attachmentId } + val attachments = attachmentIds.associateWith { File.createTempFile("file", "txt") } + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectObtainSendPreferencesSucceeds { sendPreferences } + expectReadAttachmentsFromStorageSucceeds(attachmentIds, attachments) + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences, attachments) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + sendMessage(userId, messageId) + + // Then + coVerify(exactly = attachmentIds.size) { getAttachmentFiles(userId, messageId, any()) } + } + + @Test + fun `should read attachments for Clear MIME messages`() = runTest { + // Given + val sendPreferences = generateSendPreferences(encrypt = false, sign = true, pgpScheme = PackageType.ClearMime) + val attachmentIds = sampleMessage.messageBody.attachments.map { it.attachmentId } + val attachments = attachmentIds.associateWith { File.createTempFile("file", "txt") } + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectObtainSendPreferencesSucceeds { sendPreferences } + expectReadAttachmentsFromStorageSucceeds(attachmentIds, attachments) + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences, attachments) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + sendMessage(userId, messageId) + + // Then + coVerify(exactly = attachmentIds.size) { getAttachmentFiles(userId, messageId, any()) } + } + + @Test + fun `should not read attachments for non MIME messages`() = runTest { + // Given + val sendPreferences = generateSendPreferences(encrypt = true, sign = true, pgpScheme = PackageType.ProtonMail) + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectObtainSendPreferencesSucceeds { sendPreferences } + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + sendMessage(userId, messageId) + + // Then + coVerify(exactly = 0) { getAttachmentFiles(any(), any(), any()) } + } + + @Test + fun `when message packages fail to be generated, the error is propagated to the calling site`() = runTest { + // Given + val senderAddress = UserAddressSample.PrimaryAddress + val sendPreferences = expectObtainSendPreferencesSucceeds { generateSendPreferences() } + val expectedError = SendMessage.Error.GeneratingPackages.left() + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectMessagePasswordDoesNotExist() + coEvery { + generateMessagePackages( + senderAddress, + sampleMessage, + sendPreferences.forMessagePackages(), + emptyMap(), + null, + null + ) + } returns GenerateMessagePackages.Error.GeneratingPackages("generateMessagePackages exception").left() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(expectedError, result) + } + + @Test + fun `when remote data source fails to send the message, the error is propagated to the calling site`() = runTest { + // Given + val sendPreferences = expectObtainSendPreferencesSucceeds { generateSendPreferences() } + val expectedError = SendMessage.Error.SendingToApi( + DataError.Remote.Proton(ProtonError.Unknown) + ).left() + + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences) + coEvery { + messageRemoteDataSource.send( + userId, + messageId.id, + generateSendMessageBody(messagePackages) + ) + } returns DataError.Remote.Proton(ProtonError.fromProtonCode(503)).left() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(expectedError, result) + } + + @Test + fun `when sending goes through, the success is propagated to the calling site`() = runTest { + // Given + val sendPreferences = expectObtainSendPreferencesSucceeds { generateSendPreferences() } + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(Unit.right(), result) + } + + @Test + fun `when requiring send preference passing the same address multiple times only one preference is returned`() = + runTest { + // Given + val recipient = Participant("duplicatedRecipient@pm.me", "name") + val expectedMessage = expectFindLocalDraftSucceeds { + generateDraftMessage(listOf(recipient), listOf(recipient)) + } + val addresses = expectedMessage.message.run { toList + ccList + bccList }.map { it.address } + val sendPreferences = expectObtainSendPreferencesSucceeds(recipientAddresses = addresses) { + generateSendPreferences(emailAddress = recipient.address) + } + expectResolveUserAddressSucceeds(addressId = expectedMessage.message.addressId) + expectObserveMailSettingsReturnsNull() + val messagePackages = expectGenerateMessagePackagesSucceeds( + sendPreferences = sendPreferences, expectedMessage = expectedMessage + ) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(Unit.right(), result) + } + + @Test + fun `when requiring send preference passing the same canonical address multiple times only one pref is returned`() = + runTest { + // Given + val recipientUppercase = Participant("DUPLICATEDRECIPIENT@pm.me", "name") + val recipientLowercase = Participant("duplicatedrecipient@pm.me", "name") + val expectedMessage = expectFindLocalDraftSucceeds { + generateDraftMessage(listOf(recipientUppercase), listOf(recipientLowercase)) + } + val addresses = expectedMessage.message.run { toList + ccList + bccList }.map { it.address } + val sendPreferences = expectObtainSendPreferencesSucceeds(recipientAddresses = addresses) { + generateSendPreferences(emailAddress = recipientLowercase.address) + } + expectResolveUserAddressSucceeds(addressId = expectedMessage.message.addressId) + expectObserveMailSettingsReturnsNull() + val messagePackages = expectGenerateMessagePackagesSucceeds( + sendPreferences = sendPreferences, expectedMessage = expectedMessage + ) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeDoesNotExist() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(Unit.right(), result) + } + + @Test + fun `when obtain send preferences throws an exception, the error is propagated to the calling site`() = runTest { + // Given + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + expectObtainSendPreferencesThrows(recipients) { Exception("Unexpected exception from core lib") } + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(SendMessage.Error.SendPreferences(emptyMap()).left(), result) + } + + @Test + fun `when message password exists, get random modulus`() = runTest { + // Given + val sendPreferences = expectObtainSendPreferencesSucceeds { generateSendPreferences() } + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences, doesMessagePasswordExist = true) + expectSendOperationSucceeds(messagePackages) + expectMessagePasswordExists() + expectGetSessionIdSucceeds() + expectGetRandomModulusSucceeds() + expectMessageExpirationTimeDoesNotExist() + + // When + val result = sendMessage(userId, messageId) + + // Then + assertEquals(Unit.right(), result) + coVerify { + accountRepository.getSessionIdOrNull(userId) + authRepository.randomModulus(SendMessageSample.SessionId) + } + } + + @Test + fun `when message expiration time is set, include it in the send message request body`() = runTest { + // Given + val messageExpiresInSeconds = SendMessageSample.MessageExpirationTime.expiresIn.inWholeSeconds + val sendPreferences = expectObtainSendPreferencesSucceeds { generateSendPreferences() } + expectFindLocalDraftSucceeds() + expectResolveUserAddressSucceeds() + expectObserveMailSettingsReturnsNull() + val messagePackages = expectGenerateMessagePackagesSucceeds(sendPreferences) + expectSendOperationSucceeds(messagePackages, messageExpiresInSeconds) + expectMessagePasswordDoesNotExist() + expectMessageExpirationTimeExists() + + // When + val result = sendMessage(userId, messageId) + + // Then + val sendMessageBody = generateSendMessageBody(messagePackages, messageExpiresInSeconds) + coVerify { messageRemoteDataSource.send(userId, messageId.id, sendMessageBody) } + assertEquals(Unit.right(), result) + } + + private fun expectFindLocalDraftSucceeds(expected: () -> MessageWithBody = { sampleMessage }) = expected().also { + coEvery { findLocalDraft.invoke(userId, messageId) } returns it + } + + private fun expectResolveUserAddressSucceeds( + userId: UserId = this.userId, + addressId: AddressId = sampleMessage.message.addressId + ) { + coEvery { resolveUserAddress(userId, addressId) } returns UserAddressSample.PrimaryAddress.right() + } + + private fun expectObserveMailSettingsReturnsNull() { + coEvery { observeMailSettings.invoke(userId) } returns flowOf(null) + } + + private fun expectObtainSendPreferencesThrows( + recipientAddresses: List = this.recipients, + expected: () -> Exception + ) = expected().also { exception -> + coEvery { obtainSendPreferences(userId, recipientAddresses) } answers { throw exception } + } + + private fun expectObtainSendPreferencesSucceeds( + recipientAddresses: List = this.recipients, + expected: () -> Map + ) = expected().also { + coEvery { obtainSendPreferences(userId, recipientAddresses) } returns it + } + + private fun expectReadAttachmentsFromStorageSucceeds( + attachmentIds: List, + attachments: Map + ) { + coEvery { + getAttachmentFiles(userId, messageId, attachmentIds) + } returns attachments.right() + } + + private fun expectGenerateMessagePackagesSucceeds( + sendPreferences: Map, + attachments: Map = emptyMap(), + expectedMessage: MessageWithBody = sampleMessage, + doesMessagePasswordExist: Boolean = false + ) = generateSendPackages(UserAddressSample.PrimaryAddress.email).also { messagePackages -> + coEvery { + generateMessagePackages( + UserAddressSample.PrimaryAddress, + expectedMessage, + sendPreferences.forMessagePackages(), + attachments, + if (doesMessagePasswordExist) SendMessageSample.MessagePassword else null, + if (doesMessagePasswordExist) SendMessageSample.Modulus else null + ) + } returns messagePackages.right() + } + + private fun expectSendOperationSucceeds(messagePackages: List, expiresIn: Long = 0) { + coEvery { + messageRemoteDataSource.send( + userId, + messageId.id, + generateSendMessageBody(messagePackages, expiresIn) + ) + } returns SendMessageResponse(200, mockk()).right() + } + + private fun expectMessagePasswordExists() { + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(SendMessageSample.MessagePassword) + } + + private fun expectMessagePasswordDoesNotExist() { + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + } + + private fun expectMessageExpirationTimeExists() { + coEvery { + observeMessageExpirationTime(userId, messageId) + } returns flowOf(SendMessageSample.MessageExpirationTime) + } + + private fun expectMessageExpirationTimeDoesNotExist() { + coEvery { observeMessageExpirationTime(userId, messageId) } returns flowOf(null) + } + + private fun expectGetSessionIdSucceeds() { + coEvery { accountRepository.getSessionIdOrNull(userId) } returns SendMessageSample.SessionId + } + + private fun expectGetRandomModulusSucceeds() { + coEvery { authRepository.randomModulus(SendMessageSample.SessionId) } returns SendMessageSample.Modulus + } + + private fun generateDraftMessage( + toRecipients: List, + ccRecipients: List = emptyList() + ): MessageWithBody { + val message = MessageSample.build(toList = toRecipients, ccList = ccRecipients) + val messageBody = MessageBodyTestData.messageBodyWithAttachment + return MessageWithBodySample.Invoice.copy(message = message, messageBody = messageBody) + } + + private fun generateSendPreferences( + encrypt: Boolean = true, + sign: Boolean = true, + pgpScheme: PackageType = PackageType.ProtonMail, + mimeType: MimeType = MimeType.Html, + publicKey: PublicKey? = null, + emailAddress: String = baseRecipient.address + ): Map { + return mapOf( + Pair( + emailAddress, + ObtainSendPreferences.Result.Success( + SendPreferences( + encrypt = encrypt, + sign = sign, + pgpScheme = pgpScheme, + mimeType = mimeType, + publicKey = publicKey + ) + ) + ) + ) + } + + private fun Map.forMessagePackages(): Map { + return this.map { + require(it.value is ObtainSendPreferences.Result.Success) { "Unsupported send preferences" } + Pair(it.key, (it.value as? ObtainSendPreferences.Result.Success)!!.sendPreferences) + }.toMap() + } + + @OptIn(ExperimentalEncodingApi::class) + private fun generateSendPackages(recipient: String): List { + return listOf( + SendMessagePackage( + addresses = mapOf(recipient to SendMessagePackage.Address.ExternalCleartext(signature = false.toInt())), + mimeType = SendMessageSample.SendPreferences.Cleartext.mimeType.value, + body = Base64.encode(SendMessageSample.EncryptedBodyDataPacket), + type = PackageType.Cleartext.type + ) + ) + } + + private fun generateSendMessageBody(packages: List, expiresIn: Long = 0) = SendMessageBody( + expiresIn = expiresIn, + autoSaveContacts = false.toInt(), + packages = packages + ) +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachmentsTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachmentsTest.kt new file mode 100644 index 0000000000..46dbe7cf8f --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadAttachmentsTest.kt @@ -0,0 +1,428 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import java.io.File +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.remote.AttachmentRemoteDataSource +import ch.protonmail.android.composer.data.remote.UploadAttachmentModel +import ch.protonmail.android.composer.data.remote.response.UploadAttachmentResponse +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.AttachmentStateSample +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailmessage.data.sample.AttachmentResourceSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.pgp.exception.CryptoException +import kotlin.test.Test +import kotlin.test.assertEquals + +class UploadAttachmentsTest { + + private val userId = UserIdSample.Primary + private val senderAddress = UserAddressSample.PrimaryAddress + private val messageId = MessageIdSample.RemoteDraft + private val localDraft = MessageWithBodySample.RemoteDraft + private val localStoredAttachment = File.createTempFile("attachment", "txt") + private val encryptedAttachmentResult = EncryptedAttachmentResult( + keyPacket = "key_packet".toByteArray(), + encryptedAttachment = File.createTempFile("encrypted_attachment", "txt"), + signature = "signature".toByteArray() + ) + private val expectedMessageAttachment = buildMessageAttachment() + private val expectedRemoteAttachment = AttachmentResourceSample.build("text/plain") + private val expectedRemoteMessageAttachment = expectedRemoteAttachment.toMessageAttachment() + private val uploadExpectedAttachmentResult = UploadAttachmentResponse( + code = 1000, + attachment = expectedRemoteAttachment + ) + private val expectedUploadAttachmentModel = UploadAttachmentModel( + messageId = messageId, + fileName = localStoredAttachment.name, + mimeType = expectedMessageAttachment.mimeType, + keyPacket = encryptedAttachmentResult.keyPacket, + attachment = encryptedAttachmentResult.encryptedAttachment, + signature = encryptedAttachmentResult.signature + ) + + private val attachmentRepository = mockk() + private val attachmentStateRepository = mockk() + private val attachmentRemoteDataSource = mockk() + private val findLocalDraft = mockk() + private val encryptAndSignAttachment = mockk() + private val resolveUserAddress = mockk() + private val fakeTransactor = FakeTransactor() + + private val uploadAttachments by lazy { + UploadAttachments( + attachmentRepository, + attachmentStateRepository, + attachmentRemoteDataSource, + findLocalDraft, + encryptAndSignAttachment, + resolveUserAddress, + fakeTransactor + ) + } + + @Test + fun `when upload attachment is successful then the attachment state gets updated with the remote id`() = runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful() + expectResolveUserAddressSuccessful() + expectReadingFileFromStorageSuccessful() + expectLoadingAttachmentMetadataSuccessful() + expectEncryptingAndSigningSuccessful() + expectUploadSuccessful() + expectUpdateAttachmentStateSuccessful() + expectUpdateMessageAttachmentSuccessful() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerifyOrder { + attachmentRepository.updateMessageAttachment( + userId, + messageId, + AttachmentStateSample.LocalAttachmentState.attachmentId, + expectedRemoteMessageAttachment + ) + attachmentStateRepository.setAttachmentToUploadState( + userId, + messageId, + AttachmentId(uploadExpectedAttachmentResult.attachment.id) + ) + } + } + + @Test + fun `when upload attachment fails due to missing local draft then draft not found error is returned`() = runTest { + // Given + coEvery { findLocalDraft(userId, messageId) } returns null + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(AttachmentUploadError.DraftNotFound.left(), actual) + verify { attachmentStateRepository wasNot Called } + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + verify { resolveUserAddress wasNot Called } + } + + @Test + fun `when no attachment states are stored then Unit is returned`() = runTest { + // Given + expectFindLocalDraftSuccessful() + coEvery { + attachmentStateRepository.getAllAttachmentStatesForMessage(userId, messageId) + } returns emptyList() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + verify { resolveUserAddress wasNot Called } + } + + @Test + fun `when upload attachment fails due to missing sender address then Sender address not found error is returned`() = + runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful() + expectResolveUserAddressFailed() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(AttachmentUploadError.SenderAddressNotFound.left(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + } + + @Test + fun `when upload attachment fails due to missing attachment file then attachment file not found error returned`() = + runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful() + expectResolveUserAddressSuccessful() + expectReadingFileFromStorageFailed() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(AttachmentUploadError.AttachmentFileNotFound.left(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + } + + @Test + fun `when upload attachment fails due to missing attachment metadata then attachment info not found is returned`() = + runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful() + expectResolveUserAddressSuccessful() + expectReadingFileFromStorageSuccessful() + expectLoadingAttachmentMetadataFailed() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(AttachmentUploadError.AttachmentInfoNotFound.left(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + } + + @Test + fun `when upload attachment fails due to encryption error then Unit is returned`() = runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful() + expectResolveUserAddressSuccessful() + expectReadingFileFromStorageSuccessful() + expectLoadingAttachmentMetadataSuccessful() + expectEncryptingAndSigningFailed() + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(AttachmentUploadError.FailedToEncryptAttachment.left(), actual) + verify { attachmentRemoteDataSource wasNot Called } + } + + @Test + fun `when the attachment state is Uploaded then the attachment is not uploaded`() = runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful( + listOf(AttachmentStateSample.LocalAttachmentState.copy(state = AttachmentSyncState.Uploaded)) + ) + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + verify { resolveUserAddress wasNot Called } + } + + @Test + fun `when the attachment state is ParentUploaded then the attachment is not uploaded`() = runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful( + listOf(AttachmentStateSample.LocalAttachmentState.copy(state = AttachmentSyncState.ExternalUploaded)) + ) + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + verify { resolveUserAddress wasNot Called } + } + + @Test + fun `when the attachment state is Parent then the attachment is not uploaded`() = runTest { + // Given + expectFindLocalDraftSuccessful() + expectGetAllAttachmentStatesSuccessful( + listOf(AttachmentStateSample.LocalAttachmentState.copy(state = AttachmentSyncState.External)) + ) + + // When + val actual = uploadAttachments(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + verify { attachmentRemoteDataSource wasNot Called } + verify { attachmentRepository wasNot Called } + verify { encryptAndSignAttachment wasNot Called } + verify { resolveUserAddress wasNot Called } + } + + private fun expectFindLocalDraftSuccessful() { + coEvery { findLocalDraft(userId, messageId) } returns localDraft + } + + private fun expectGetAllAttachmentStatesSuccessful( + states: List = listOf(AttachmentStateSample.LocalAttachmentState) + ) { + coEvery { + attachmentStateRepository.getAllAttachmentStatesForMessage(userId, messageId) + } returns states + } + + private fun expectResolveUserAddressSuccessful() { + coEvery { + resolveUserAddress(userId, localDraft.message.addressId) + } returns senderAddress.right() + } + + private fun expectResolveUserAddressFailed() { + coEvery { + resolveUserAddress(userId, localDraft.message.addressId) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + } + + private fun expectReadingFileFromStorageSuccessful( + attachmentId: AttachmentId = AttachmentStateSample.LocalAttachmentState.attachmentId, + attachment: File = localStoredAttachment + ) { + coEvery { + attachmentRepository.readFileFromStorage( + userId, + messageId, + attachmentId + ) + } returns attachment.right() + } + + private fun expectReadingFileFromStorageFailed( + attachmentId: AttachmentId = AttachmentStateSample.LocalAttachmentState.attachmentId + ) { + coEvery { + attachmentRepository.readFileFromStorage( + userId, + messageId, + attachmentId + ) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectLoadingAttachmentMetadataSuccessful( + attachmentId: AttachmentId = AttachmentStateSample.LocalAttachmentState.attachmentId, + messageAttachment: MessageAttachment = expectedMessageAttachment + ) { + coEvery { + attachmentRepository.getAttachmentInfo( + userId, + messageId, + attachmentId + ) + } returns messageAttachment.right() + } + + private fun expectLoadingAttachmentMetadataFailed() { + coEvery { + attachmentRepository.getAttachmentInfo( + userId, + messageId, + AttachmentStateSample.LocalAttachmentState.attachmentId + ) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectEncryptingAndSigningSuccessful() { + coEvery { + encryptAndSignAttachment( + senderAddress, + localStoredAttachment + ) + } returns encryptedAttachmentResult.right() + } + + private fun expectEncryptingAndSigningFailed(file: File = localStoredAttachment) { + coEvery { + encryptAndSignAttachment( + senderAddress, + file + ) + } returns AttachmentEncryptionError.FailedToEncryptAttachment(CryptoException("Failed")).left() + } + + private fun expectUploadSuccessful() { + coEvery { + attachmentRemoteDataSource.uploadAttachment(userId, expectedUploadAttachmentModel) + } returns uploadExpectedAttachmentResult.right() + } + + private fun expectUpdateAttachmentStateSuccessful() { + coEvery { + attachmentStateRepository.setAttachmentToUploadState( + userId, + messageId, + AttachmentId(uploadExpectedAttachmentResult.attachment.id) + ) + } returns Unit.right() + } + + private fun expectUpdateMessageAttachmentSuccessful() { + coEvery { + attachmentRepository.updateMessageAttachment( + userId, + messageId, + AttachmentStateSample.LocalAttachmentState.attachmentId, + expectedRemoteMessageAttachment + ) + } returns Unit.right() + } + + private fun buildMessageAttachment(attachmentId: AttachmentId = AttachmentId("attachment_id")) = MessageAttachment( + attachmentId = attachmentId, + name = localStoredAttachment.name, + size = localStoredAttachment.length(), + mimeType = "text/plain", + disposition = null, + keyPackets = null, + signature = null, + encSignature = null, + headers = emptyMap() + ) +} diff --git a/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraftTest.kt b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraftTest.kt new file mode 100644 index 0000000000..2a6e7be9fe --- /dev/null +++ b/mail-composer/data/src/test/kotlin/ch/protonmail/android/composer/data/usecase/UploadDraftTest.kt @@ -0,0 +1,510 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.composer.data.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.composer.data.remote.DraftRemoteDataSource +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.ProtonError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailcomposer.domain.usecase.CreateOrUpdateParentAttachmentStates +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploadTracker +import ch.protonmail.android.mailcomposer.domain.usecase.FindLocalDraft +import ch.protonmail.android.mailcomposer.domain.usecase.IsDraftKnownToApi +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class UploadDraftTest { + + @get:Rule + val loggingRule = LoggingTestRule() + + private val userId = UserIdSample.Primary + + private val messageRepository = mockk() + private val findLocalDraft = mockk() + private val draftRemoteDataSource = mockk() + private val draftStateRepository = mockk() + private val isDraftKnownToApi = mockk() + private val fakeTransactor = FakeTransactor() + private val attachmentRepository = mockk() + private val updatedAttachmentStates = mockk() + private val draftUploadTracker = mockk() + + private val draftRepository = UploadDraft( + fakeTransactor, + messageRepository, + findLocalDraft, + draftStateRepository, + draftRemoteDataSource, + isDraftKnownToApi, + attachmentRepository, + updatedAttachmentStates, + draftUploadTracker + ) + + @Test + fun `returns success when remote data source succeeds`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val expectedDraft = MessageWithBodySample.Invoice + val expectedAction = DraftAction.Compose + val expectedResponse = MessageWithBodySample.EmptyDraft + val expectedDraftState = DraftStateSample.NewDraftState + val apiAssignedMessageId = expectedResponse.message.messageId + val apiAssignedConversationId = expectedResponse.message.conversationId + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceCreateSuccess(userId, expectedDraft, expectedAction, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, apiAssignedMessageId) + expectMessageUpdateSuccess(userId, messageId, apiAssignedMessageId, apiAssignedConversationId) + expectIsDraftKnownToApi(expectedDraftState, false) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerify(exactly = 0) { draftRemoteDataSource.update(any(), any()) } + } + + @Test + fun `update message and draft state with api message id when create call succeeds`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val apiAssignedMessageId = MessageIdSample.RemoteDraft + val expectedDraft = MessageWithBodySample.NewDraftWithSubject + val expectedAction = DraftAction.Compose + val expectedResponse = MessageWithBodySample.RemoteDraft + val expectedDraftState = DraftStateSample.NewDraftState + val apiAssignedConversationId = expectedResponse.message.conversationId + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceCreateSuccess(userId, expectedDraft, expectedAction, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, apiAssignedMessageId) + expectMessageUpdateSuccess(userId, messageId, apiAssignedMessageId, apiAssignedConversationId) + expectIsDraftKnownToApi(expectedDraftState, false) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerify { + messageRepository.updateDraftRemoteIds( + userId, + messageId, + apiAssignedMessageId, + apiAssignedConversationId + ) + } + coVerify { draftStateRepository.updateApiMessageIdAndSetSyncedState(userId, messageId, apiAssignedMessageId) } + } + + @Test + fun `update draft state to synced when update call succeeds`() = runTest { + // Given + val messageId = MessageIdSample.RemoteDraft + val expectedDraft = MessageWithBodySample.RemoteDraft + val expectedResponse = MessageWithBodySample.RemoteDraft + val expectedDraftState = DraftStateSample.RemoteDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceUpdateSuccess(userId, expectedDraft, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, messageId) + expectIsDraftKnownToApi(expectedDraftState, true) + expectDraftUploadTrackerNotified(messageId, expectedDraft) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerify { draftStateRepository.updateApiMessageIdAndSetSyncedState(userId, messageId, messageId) } + } + + @Test + fun `returns local failure when reading the message from DB fails`() = runTest { + // Given + val messageId = MessageIdSample.Invoice + val expectedError = DataError.Local.NoDataCached + val expectedDraftState = DraftStateSample.NewDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageFails(userId, messageId) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(expectedError.left(), actual) + loggingRule.assertDebugLogged("Sync draft failure $messageId: No message found") + } + + @Test + fun `returns local failure when reading the draft state from DB fails`() = runTest { + // Given + val messageId = MessageIdSample.Invoice + val expectedDraft = MessageWithBodySample.Invoice + val expectedError = DataError.Local.NoDataCached + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectGetDraftStateFails(userId, messageId, expectedError) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(expectedError.left(), actual) + } + + @Test + fun `returns remote failure when remote data source fails`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val expectedDraft = MessageWithBodySample.Invoice + val expectedAction = DraftAction.Compose + val expectedError = DataError.Remote.Proton(ProtonError.MessageUpdateDraftNotDraft) + val expectedDraftState = DraftStateSample.NewDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceFailure(userId, expectedDraft, expectedAction, expectedError) + expectIsDraftKnownToApi(expectedDraftState, false) + + // When + val actual = draftRepository(userId, messageId) + + // Then + loggingRule.assertWarningLogged("Sync draft failure $messageId: Create API call error $expectedError") + assertEquals(expectedError.left(), actual) + } + + @Test + fun `does not log failure on sentry when remote data source fails with create draft request not performed`() = + runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val expectedDraft = MessageWithBodySample.Invoice + val expectedAction = DraftAction.Compose + val expectedError = DataError.Remote.CreateDraftRequestNotPerformed + val expectedDraftState = DraftStateSample.NewDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceFailure(userId, expectedDraft, expectedAction, expectedError) + expectIsDraftKnownToApi(expectedDraftState, false) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(expectedError.left(), actual) + loggingRule.assertNoWarningLogs() + } + + @Test + fun `sync performs update on remote data source when draft is already known to the API`() = runTest { + // Given + val messageId = MessageIdSample.RemoteDraft + val expectedDraft = MessageWithBodySample.RemoteDraft + val expectedResponse = MessageWithBodySample.RemoteDraft + val expectedDraftState = DraftStateSample.RemoteDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceUpdateSuccess(userId, expectedDraft, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, messageId) + expectIsDraftKnownToApi(expectedDraftState, true) + expectDraftUploadTrackerNotified(messageId, expectedDraft) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerify(exactly = 0) { draftRemoteDataSource.create(any(), any(), any()) } + coVerify { draftUploadTracker.notifyUploadedDraft(messageId, expectedDraft) } + } + + @Test + fun `update local attachment id and key packets when create call succeeds`() = runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val expectedDraft = MessageWithBodySample.MessageWithInvoiceAttachment + val expectedLocalAttachment = expectedDraft.messageBody.attachments.first() + val expectedUpdatedAttachment = expectedLocalAttachment.copy( + attachmentId = AttachmentId("Api-defined-id"), + keyPackets = "keyPackets" + ) + val expectedResponse = expectedDraft.copy( + messageBody = expectedDraft.messageBody.copy(attachments = listOf(expectedUpdatedAttachment)) + ) + val apiAssignedMessageId = expectedResponse.message.messageId + val apiAssignedConversationId = expectedResponse.message.conversationId + val expectedDraftState = DraftStateSample.LocalDraftWithForwardAction + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceCreateSuccess(userId, expectedDraft, expectedDraftState.action, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, messageId) + expectIsDraftKnownToApi(expectedDraftState, false) + expectStoreSyncedStateSuccess(userId, messageId, apiAssignedMessageId) + expectMessageUpdateSuccess(userId, messageId, apiAssignedMessageId, apiAssignedConversationId) + expectAttachmentUpdateSuccess( + userId, apiAssignedMessageId, expectedLocalAttachment.attachmentId, expectedUpdatedAttachment + ) + expectStoreParentAttachmentSucceeds( + userId, apiAssignedMessageId, listOf(expectedUpdatedAttachment.attachmentId) + ) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + coVerifySequence { + attachmentRepository.updateMessageAttachment( + userId, + apiAssignedMessageId, + expectedLocalAttachment.attachmentId, + expectedUpdatedAttachment + ) + updatedAttachmentStates( + userId, + apiAssignedMessageId, + listOf(expectedUpdatedAttachment.attachmentId) + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Should match local and remote attachments by keyPackets, name and mimeType when updating local ids, despite different attachment sizes`() = + runTest { + // Given + val messageId = MessageIdSample.LocalDraft + val expectedLocalAttachments = listOf( + MessageAttachmentSample.embeddedImageAttachment.copy( + keyPackets = "keyPackets" + ), + MessageAttachmentSample.embeddedOctetStreamAttachment.copy( + keyPackets = "keyPackets" + ), + MessageAttachmentSample.image.copy( + keyPackets = "keyPackets" + ) + ) + val expectedDraft = MessageWithBodySample.MessageWithInvoiceAttachment.copy( + messageBody = MessageWithBodySample.MessageWithInvoiceAttachment.messageBody.copy( + attachments = expectedLocalAttachments + ) + ) + + val expectedUpdatedAttachments = listOf( + MessageAttachmentSample.embeddedImageAttachment.copy( + attachmentId = AttachmentId("Api-defined-id-1"), + keyPackets = "keyPackets", + size = 666 + ), + MessageAttachmentSample.embeddedOctetStreamAttachment.copy( + attachmentId = AttachmentId("Api-defined-id-2"), + keyPackets = "keyPackets", + size = 666 + ), + MessageAttachmentSample.image.copy( + attachmentId = AttachmentId("Api-defined-id-3"), + keyPackets = "keyPackets", + size = 666 + ) + ) + + val expectedResponse = expectedDraft.copy( + messageBody = expectedDraft.messageBody.copy(attachments = expectedUpdatedAttachments) + ) + val apiAssignedMessageId = expectedResponse.message.messageId + val apiAssignedConversationId = expectedResponse.message.conversationId + val expectedDraftState = DraftStateSample.LocalDraftWithForwardAction + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedDraft) + expectRemoteDataSourceCreateSuccess(userId, expectedDraft, expectedDraftState.action, expectedResponse) + expectStoreSyncedStateSuccess(userId, messageId, messageId) + expectIsDraftKnownToApi(expectedDraftState, false) + expectStoreSyncedStateSuccess(userId, messageId, apiAssignedMessageId) + expectMessageUpdateSuccess(userId, messageId, apiAssignedMessageId, apiAssignedConversationId) + for (i in expectedLocalAttachments.indices) { + expectAttachmentUpdateSuccess( + userId, + apiAssignedMessageId, + expectedLocalAttachments[i].attachmentId, + expectedUpdatedAttachments[i] + ) + } + expectStoreParentAttachmentSucceeds( + userId, apiAssignedMessageId, expectedUpdatedAttachments.map { it.attachmentId } + ) + + // When + val actual = draftRepository(userId, messageId) + + // Then + assertEquals(Unit.right(), actual) + for (i in expectedLocalAttachments.indices) { + coVerify { + attachmentRepository.updateMessageAttachment( + userId, + apiAssignedMessageId, + expectedLocalAttachments[i].attachmentId, + expectedUpdatedAttachments[i] + ) + } + } + + coVerify { + updatedAttachmentStates( + userId, + apiAssignedMessageId, + expectedUpdatedAttachments.map { it.attachmentId } + ) + } + } + + private fun expectAttachmentUpdateSuccess( + userId: UserId, + messageId: MessageId, + localAttachmentId: AttachmentId, + updatedAttachment: MessageAttachment + ) { + coEvery { + attachmentRepository.updateMessageAttachment(userId, messageId, localAttachmentId, updatedAttachment) + } returns Unit.right() + } + + private fun expectIsDraftKnownToApi(draftSTate: DraftState, isKnown: Boolean) { + coEvery { isDraftKnownToApi(draftSTate) } returns isKnown + } + + private fun expectMessageUpdateSuccess( + userId: UserId, + messageId: MessageId, + remoteMessageId: MessageId, + remoteConversationId: ConversationId + ) { + coEvery { + messageRepository.updateDraftRemoteIds(userId, messageId, remoteMessageId, remoteConversationId) + } returns Unit + } + + private fun expectStoreSyncedStateSuccess( + userId: UserId, + messageId: MessageId, + apiAssignedMessageId: MessageId + ) { + coEvery { + draftStateRepository.updateApiMessageIdAndSetSyncedState(userId, messageId, apiAssignedMessageId) + } returns Unit.right() + } + + private fun expectRemoteDataSourceCreateSuccess( + userId: UserId, + messageWithBody: MessageWithBody, + action: DraftAction, + response: MessageWithBody + ) { + coEvery { draftRemoteDataSource.create(userId, messageWithBody, action) } returns response.right() + } + + private fun expectRemoteDataSourceUpdateSuccess( + userId: UserId, + messageWithBody: MessageWithBody, + response: MessageWithBody + ) { + coEvery { draftRemoteDataSource.update(userId, messageWithBody) } returns response.right() + } + + + private fun expectRemoteDataSourceFailure( + userId: UserId, + messageWithBody: MessageWithBody, + action: DraftAction, + error: DataError.Remote + ) { + coEvery { draftRemoteDataSource.create(userId, messageWithBody, action) } returns error.left() + } + + private fun expectGetLocalMessageSucceeds( + userId: UserId, + messageId: MessageId, + expectedMessage: MessageWithBody + ) { + coEvery { findLocalDraft(userId, messageId) } returns expectedMessage + } + + private fun expectGetLocalMessageFails(userId: UserId, messageId: MessageId) { + coEvery { findLocalDraft(userId, messageId) } returns null + } + + private fun expectGetDraftStateSucceeds( + userId: UserId, + messageId: MessageId, + expectedState: DraftState + ) { + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(expectedState.right()) + } + + private fun expectGetDraftStateFails( + userId: UserId, + messageId: MessageId, + error: DataError.Local + ) { + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(error.left()) + } + + private fun expectStoreParentAttachmentSucceeds( + userId: UserId, + messageId: MessageId, + attachmentIds: List + ) { + coJustRun { updatedAttachmentStates(userId, messageId, attachmentIds) } + } + + private fun expectDraftUploadTrackerNotified(messageId: MessageId, expectedMessage: MessageWithBody) { + coJustRun { draftUploadTracker.notifyUploadedDraft(messageId, expectedMessage) } + } +} diff --git a/mail-composer/domain/build.gradle.kts b/mail-composer/domain/build.gradle.kts new file mode 100644 index 0000000000..2436667567 --- /dev/null +++ b/mail-composer/domain/build.gradle.kts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailcomposer.domain" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.dagger.hilt.android) + + implementation(libs.bundles.module.domain) + implementation(libs.jsoup) + + implementation(libs.proton.core.user) + implementation(libs.proton.core.label) + implementation(libs.proton.core.mailSettings) + + implementation(project(":mail-message:domain")) + implementation(project(":mail-common:domain")) + implementation(project(":mail-label:domain")) + implementation(project(":mail-pagination:domain")) + implementation(project(":mail-settings:domain")) + + testImplementation(libs.bundles.test) + // Used to access sample test data (here instead of test-data as shared with compose previews / android tests) + testImplementation(project(":mail-common:domain")) + testImplementation(project(":test:test-data")) + testImplementation(project(":test:utils")) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/Transactor.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/Transactor.kt new file mode 100644 index 0000000000..6cd2578f9d --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/Transactor.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain + +/** + * Implementers of this interface will allow to perform a data operation in a single "transaction" + * which will grant both synchronization and atomicity of the operation. + */ +interface Transactor { + + suspend fun performTransaction(block: suspend () -> T): T +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/annotation/IsComposerV2Enabled.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/annotation/IsComposerV2Enabled.kt new file mode 100644 index 0000000000..4ac9206a45 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/annotation/IsComposerV2Enabled.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.annotation + +import javax.inject.Qualifier + +/** + * Indicates whether Composer V2 is accessible. + */ +@Qualifier +annotation class IsComposerV2Enabled diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/AddressPublicKey.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/AddressPublicKey.kt new file mode 100644 index 0000000000..e1bdd3cda2 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/AddressPublicKey.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +data class AddressPublicKey( + val fileName: String, + val mimeType: String, + val bytes: ByteArray +) { + + @Suppress("ReturnCount") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddressPublicKey + + if (fileName != other.fileName) return false + if (mimeType != other.mimeType) return false + if (!bytes.contentEquals(other.bytes)) return false + + return true + } + + override fun hashCode(): Int { + var result = fileName.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DecryptedDraftFields.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DecryptedDraftFields.kt new file mode 100644 index 0000000000..3b6fb8bb3b --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DecryptedDraftFields.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +sealed interface DecryptedDraftFields { + + val draftFields: DraftFields + + data class Remote(override val draftFields: DraftFields) : DecryptedDraftFields + + data class Local(override val draftFields: DraftFields) : DecryptedDraftFields +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftBody.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftBody.kt new file mode 100644 index 0000000000..bcde484ace --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftBody.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +@JvmInline +value class DraftBody(val value: String) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftFields.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftFields.kt new file mode 100644 index 0000000000..005a5bca15 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/DraftFields.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +data class DraftFields( + val sender: SenderEmail, + val subject: Subject, + val body: DraftBody, + val recipientsTo: RecipientsTo, + val recipientsCc: RecipientsCc, + val recipientsBcc: RecipientsBcc, + val originalHtmlQuote: OriginalHtmlQuote? +) { + /** + * Returns true if all of the fields (except sender and quoted html body) are blank. + * Can be used to infer whether these fields should be used to store or discard a draft. + */ + fun areBlank() = haveBlankSubject() && + haveBlankRecipients() && + body.value.isBlank() + + fun haveBlankSubject() = subject.value.isBlank() + + fun haveBlankRecipients() = recipientsTo.value.isEmpty() && + recipientsCc.value.isEmpty() && + recipientsBcc.value.isEmpty() +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageExpirationTime.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageExpirationTime.kt new file mode 100644 index 0000000000..32aa984729 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageExpirationTime.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import kotlin.time.Duration + +data class MessageExpirationTime( + val userId: UserId, + val messageId: MessageId, + val expiresIn: Duration +) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessagePassword.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessagePassword.kt new file mode 100644 index 0000000000..99bb88e3bd --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessagePassword.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +data class MessagePassword( + val userId: UserId, + val messageId: MessageId, + val password: String, + val passwordHint: String? +) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageSendingStatus.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageSendingStatus.kt new file mode 100644 index 0000000000..6c905b7c39 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageSendingStatus.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +sealed interface MessageSendingStatus { + object MessageSent : MessageSendingStatus + object SendMessageError : MessageSendingStatus + object UploadAttachmentsError : MessageSendingStatus + object None : MessageSendingStatus +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageWithDecryptedBody.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageWithDecryptedBody.kt new file mode 100644 index 0000000000..3347f488b0 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/MessageWithDecryptedBody.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody + +data class MessageWithDecryptedBody( + val messageWithBody: MessageWithBody, + val decryptedMessageBody: DecryptedMessageBody +) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/QuotedHtmlContent.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/QuotedHtmlContent.kt new file mode 100644 index 0000000000..530f9a454b --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/QuotedHtmlContent.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +data class QuotedHtmlContent( + val original: OriginalHtmlQuote, + val styled: StyledHtmlQuote +) + +@JvmInline +value class OriginalHtmlQuote(val value: String) + +@JvmInline +value class StyledHtmlQuote(val value: String) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsBcc.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsBcc.kt new file mode 100644 index 0000000000..fb0480cb6f --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsBcc.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.Recipient + +@JvmInline +value class RecipientsBcc(val value: List) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsCc.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsCc.kt new file mode 100644 index 0000000000..553c812427 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsCc.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.Recipient + +@JvmInline +value class RecipientsCc(val value: List) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsTo.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsTo.kt new file mode 100644 index 0000000000..e7ce4ff72d --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/RecipientsTo.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import ch.protonmail.android.mailmessage.domain.model.Recipient + +@JvmInline +value class RecipientsTo(val value: List) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/SenderEmail.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/SenderEmail.kt new file mode 100644 index 0000000000..b398163ce3 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/SenderEmail.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +import kotlinx.serialization.Serializable + +@JvmInline +@Serializable +value class SenderEmail(val value: String) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/Subject.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/Subject.kt new file mode 100644 index 0000000000..f3ea26f7c1 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/model/Subject.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.model + +@JvmInline +value class Subject(val value: String) diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentRepository.kt new file mode 100644 index 0000000000..8efbf08575 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentRepository.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +interface AttachmentRepository { + + suspend fun deleteAttachment( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + + @Suppress("LongParameterList") + suspend fun createAttachment( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId, + fileName: String, + mimeType: String, + content: ByteArray + ): Either + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentStateRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentStateRepository.kt new file mode 100644 index 0000000000..a59e85f1f0 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/AttachmentStateRepository.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +interface AttachmentStateRepository { + + suspend fun getAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + + suspend fun getAllAttachmentStatesForMessage(userId: UserId, messageId: MessageId): List + + suspend fun createOrUpdateLocalState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + + suspend fun createOrUpdateLocalStates( + userId: UserId, + messageId: MessageId, + attachmentIds: List, + syncState: AttachmentSyncState + ): Either + + + suspend fun setAttachmentToUploadState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + + suspend fun deleteAttachmentState( + userId: UserId, + messageId: MessageId, + attachmentId: AttachmentId + ): Either + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/ContactsPermissionRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/ContactsPermissionRepository.kt new file mode 100644 index 0000000000..65be5a25cf --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/ContactsPermissionRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import kotlinx.coroutines.flow.Flow + +interface ContactsPermissionRepository { + + fun observePermissionDenied(): Flow> + + suspend fun trackPermissionDenied() +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/DraftRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/DraftRepository.kt new file mode 100644 index 0000000000..844b540afe --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/DraftRepository.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +interface DraftRepository { + + /** + * Schedules the upload of the message with the given messageId. + * In case a job to upload this message is already ongoing or enqueued, nothing is done + */ + suspend fun upload(userId: UserId, messageId: MessageId) + + /** + * Schedules the upload of the message with the given messageId. + * In case a job to upload this message is already ongoing or enqueued, + * a new job will be chained to happen afterwards, independently of the outcome of the existing one. + */ + suspend fun forceUpload(userId: UserId, messageId: MessageId) + + /** + * Cancels the upload of the message with the given messageId. + * In case a job to upload this message is already ongoing or enqueued, it will be cancelled. + */ + fun cancelUploadDraft(messageId: MessageId) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageExpirationTimeRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageExpirationTimeRepository.kt new file mode 100644 index 0000000000..0da0dda3a3 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageExpirationTimeRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId + +interface MessageExpirationTimeRepository { + + suspend fun saveMessageExpirationTime(messageExpirationTime: MessageExpirationTime): Either + + suspend fun observeMessageExpirationTime(userId: UserId, messageId: MessageId): Flow +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessagePasswordRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessagePasswordRepository.kt new file mode 100644 index 0000000000..c2450742b8 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessagePasswordRepository.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.Flow +import me.proton.core.domain.entity.UserId + +interface MessagePasswordRepository { + + suspend fun saveMessagePassword(messagePassword: MessagePassword): Either + + suspend fun updateMessagePassword( + userId: UserId, + messageId: MessageId, + password: String, + passwordHint: String? + ): Either + + suspend fun observeMessagePassword(userId: UserId, messageId: MessageId): Flow + + suspend fun deleteMessagePassword(userId: UserId, messageId: MessageId) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageRepository.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageRepository.kt new file mode 100644 index 0000000000..08dfbe2cea --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/repository/MessageRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId + +interface MessageRepository { + + suspend fun send(userId: UserId, messageId: MessageId) + + suspend fun moveMessageFromDraftsToSent(userId: UserId, messageId: MessageId): Either + + suspend fun moveMessageBackFromSentToDrafts(userId: UserId, messageId: MessageId) + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/AttachmentStateSample.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/AttachmentStateSample.kt new file mode 100644 index 0000000000..f02b9a10ac --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/AttachmentStateSample.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.sample + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import me.proton.core.domain.entity.UserId + +object AttachmentStateSample { + + val LocalAttachmentState = build() + + val RemoteAttachmentState = build( + messageId = MessageIdSample.RemoteDraft, + state = AttachmentSyncState.Uploaded + ) + + fun build( + userId: UserId = UserIdSample.Primary, + messageId: MessageId = MessageIdSample.RemoteDraft, + attachmentId: AttachmentId = AttachmentId("attachment_id"), + state: AttachmentSyncState = AttachmentSyncState.Local + ) = AttachmentState( + userId = userId, + messageId = messageId, + attachmentId = attachmentId, + state = state + ) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/DraftStateSample.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/DraftStateSample.kt new file mode 100644 index 0000000000..ee4d10155f --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/sample/DraftStateSample.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.sample + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import me.proton.core.domain.entity.UserId + +object DraftStateSample { + + val NewDraftState = build() + + /** + * Represents a draft that was created locally and never synced to API yet + */ + val LocalDraftNeverSynced = build( + messageId = MessageIdSample.LocalDraft, + apiMessageId = null, + state = DraftSyncState.Local + ) + /** + * Represents a draft that was created locally and then synced to remote + */ + val LocalDraftThatWasSyncedOnce = build( + messageId = MessageIdSample.LocalDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Synchronized + ) + + /** + * Represents a draft that was created from another platform and being edited for the first time + * Draft state being created will have a messageId in the "remote" format but have no `apiMessageId` yet) + */ + val RemoteWithoutApiMessageId = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = null, + state = DraftSyncState.Synchronized + ) + + val RemoteDraftState = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Synchronized + ) + + /** + * Represents a remote draft that is scheduled for sending. + */ + val RemoteDraftInSendingState = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Sending + ) + + /** + * Represents a remote draft that failed to be sent. + */ + val RemoteDraftInErrorSendingState = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.ErrorSending + ) + + /** + * Represents a remote draft that failed to upload attachments. + */ + val RemoteDraftInErrorAttachmentUploadState = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.ErrorUploadAttachments + ) + + /** + * Represents a remote draft that has been successfully sent. + */ + val RemoteDraftInSentState = build( + messageId = MessageIdSample.RemoteDraft, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Sent + ) + + /** + * Represents a local draft that's forwarding a parent message + */ + val LocalDraftWithForwardAction = build( + messageId = MessageIdSample.LocalDraft, + apiMessageId = MessageIdSample.MessageWithAttachments, + state = DraftSyncState.Local, + action = DraftAction.Forward(MessageIdSample.Invoice) + ) + + fun build( + userId: UserId = UserIdSample.Primary, + messageId: MessageId = MessageIdSample.EmptyDraft, + apiMessageId: MessageId? = null, + state: DraftSyncState = DraftSyncState.Local, + action: DraftAction = DraftAction.Compose, + sendingError: SendingError? = null + ) = DraftState( + userId = userId, + messageId = messageId, + apiMessageId = apiMessageId, + state = state, + action = action, + sendingError = sendingError, + sendingStatusConfirmed = false + ) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ClearMessageSendingError.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ClearMessageSendingError.kt new file mode 100644 index 0000000000..e1279ef10a --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ClearMessageSendingError.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ClearMessageSendingError @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) = + draftStateRepository.updateSendingError(userId, messageId, null) + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatus.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatus.kt new file mode 100644 index 0000000000..368ebe5073 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatus.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ConfirmSendingMessageStatus @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) { + draftStateRepository.updateConfirmDraftSendingStatus(userId, messageId, true) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraft.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraft.kt new file mode 100644 index 0000000000..8e887427bb --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraft.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.time.Instant +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.mailmessage.domain.model.AttachmentCount +import ch.protonmail.android.mailmessage.domain.model.Message +import ch.protonmail.android.mailmessage.domain.model.MessageBody +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.model.Sender +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.util.kotlin.EMPTY_STRING +import javax.inject.Inject + +class CreateEmptyDraft @Inject constructor() { + + operator fun invoke( + messageId: MessageId, + userId: UserId, + userAddress: UserAddress + ) = MessageWithBody( + message = Message( + userId = userId, + messageId = messageId, + conversationId = ConversationId(EMPTY_STRING), + order = 0, + subject = EMPTY_STRING, + unread = false, + sender = Sender(userAddress.email, userAddress.displayName.orEmpty()), + toList = emptyList(), + ccList = emptyList(), + bccList = emptyList(), + time = Instant.now().epochSecond, + size = 0L, + expirationTime = 0L, + isReplied = false, + isRepliedAll = false, + isForwarded = false, + addressId = userAddress.addressId, + externalId = null, + numAttachments = 0, + flags = 0L, + attachmentCount = AttachmentCount(0), + labelIds = listOf( + SystemLabelId.Drafts.labelId, + SystemLabelId.AllDrafts.labelId, + SystemLabelId.AllMail.labelId + ) + ), + messageBody = MessageBody( + userId = userId, + messageId = messageId, + body = EMPTY_STRING, + header = EMPTY_STRING, + attachments = emptyList(), + mimeType = MimeType.PlainText, + spamScore = EMPTY_STRING, + replyTo = Recipient( + address = userAddress.email, + name = userAddress.displayName ?: EMPTY_STRING, + group = null + ), + replyTos = emptyList(), + unsubscribeMethods = null + ) + ) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateOrUpdateParentAttachmentStates.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateOrUpdateParentAttachmentStates.kt new file mode 100644 index 0000000000..94cfa8566d --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateOrUpdateParentAttachmentStates.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class CreateOrUpdateParentAttachmentStates @Inject constructor( + private val attachmentStateRepository: AttachmentStateRepository +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + attachmentIds: List + ) { + attachmentIds + .map { attachmentId -> + attachmentId to attachmentStateRepository.getAttachmentState(userId, messageId, attachmentId) + .mapLeft { Timber.e("Failed to load attachmentId: $it") } + .getOrNull() + } + .filter { it.second == null || it.second?.state == AttachmentSyncState.External } + .map { it.first } + .let { filteredAttachmentIds -> + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + filteredAttachmentIds, + AttachmentSyncState.ExternalUploaded + ).mapLeft { Timber.e("Failed to create or update local attachment state: $it") } + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachments.kt new file mode 100644 index 0000000000..01ca2d1937 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachments.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DeleteAllAttachments @Inject constructor( + private val localDraft: GetLocalDraft, + private val attachmentRepository: AttachmentRepository +) { + + suspend operator fun invoke( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId + ) { + val localDraft = localDraft(userId, messageId, senderEmail).getOrNull() + + if (localDraft == null) { + Timber.e("Failed to load local draft") + return + } + + localDraft.messageBody.attachments.forEach { + attachmentRepository.deleteAttachment(userId, localDraft.message.messageId, it.attachmentId) + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachment.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachment.kt new file mode 100644 index 0000000000..5bf4986c46 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachment.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class DeleteAttachment @Inject constructor( + private val localDraft: GetLocalDraft, + private val attachmentRepository: AttachmentRepository +) { + + suspend operator fun invoke( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId, + attachmentId: AttachmentId + ): Either = either { + val localDraft = localDraft(userId, messageId, senderEmail) + .mapLeft { AttachmentDeleteError.DraftNotFound } + .bind() + + attachmentRepository.deleteAttachment(userId, localDraft.message.messageId, attachmentId) + .mapLeft { + when (it) { + DataError.Local.FailedToDeleteFile -> AttachmentDeleteError.FailedToDeleteFile + else -> AttachmentDeleteError.Unknown + } + } + .bind() + } +} + +sealed interface AttachmentDeleteError { + object DraftNotFound : AttachmentDeleteError + object FailedToDeleteFile : AttachmentDeleteError + object Unknown : AttachmentDeleteError +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftState.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftState.kt new file mode 100644 index 0000000000..27856177c2 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class DeleteDraftState @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) { + draftStateRepository.deleteDraftState(userId, messageId) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePassword.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePassword.kt new file mode 100644 index 0000000000..6adf3beb43 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePassword.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DeleteMessagePassword @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val messagePasswordRepository: MessagePasswordRepository, + private val transactor: Transactor +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) = transactor.performTransaction { + val apiMessageId = draftStateRepository.observe(userId, messageId).first().onLeft { + Timber.e("No draft state found for $messageId") + }.getOrNull()?.apiMessageId + + return@performTransaction messagePasswordRepository.deleteMessagePassword(userId, apiMessageId ?: messageId) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraft.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraft.kt new file mode 100644 index 0000000000..dd0178b85f --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraft.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.usecase.DeleteMessages +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DiscardDraft @Inject constructor( + private val findLocalDraft: FindLocalDraft, + private val deleteMessages: DeleteMessages, + private val draftRepository: DraftRepository, + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) { + findLocalDraft(userId, messageId)?.message?.messageId?.let { + draftRepository.cancelUploadDraft(it) + draftStateRepository.deleteDraftState(userId, it) + deleteMessages(userId, listOf(it), SystemLabelId.Drafts.labelId) + } ?: Timber.e("No draft for discard found") + } + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTracker.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTracker.kt new file mode 100644 index 0000000000..ae40f45d7e --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTracker.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.util.concurrent.ConcurrentHashMap +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DraftUploadTracker @Inject constructor( + private val findLocalDraft: FindLocalDraft, + private val draftStateRepository: DraftStateRepository +) { + + private val lastUploadedDrafts: MutableMap = ConcurrentHashMap() + + suspend fun uploadRequired(userId: UserId, messageId: MessageId): Boolean { + + // Upload may be skipped only in Synchronised state + draftStateRepository.observe(userId, messageId).firstOrNull() + ?.takeIf { draftState -> + draftState.fold(ifRight = { it.state != DraftSyncState.Synchronized }, ifLeft = { true }) + } + ?.let { return true } + + val lastUploadedDraft = lastUploadedDrafts[messageId] + + return if (lastUploadedDraft != null) { + val localDraft = findLocalDraft(userId, messageId) + + localDraft?.let { it != lastUploadedDraft } ?: true + } else { + true + } + } + + fun notifyUploadedDraft(messageId: MessageId, messageWithBody: MessageWithBody) { + lastUploadedDrafts[messageId] = messageWithBody + } + + fun notifySentMessages(sentMessageList: Set) { + lastUploadedDrafts.keys.removeAll(sentMessageList.toSet()) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploader.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploader.kt new file mode 100644 index 0000000000..79d31ec5f4 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploader.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Singleton +class DraftUploader @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val draftRepository: DraftRepository, + @DefaultDispatcher + private val dispatcher: CoroutineDispatcher +) { + + private var syncJob: Job? = null + + fun startContinuousUpload( + userId: UserId, + messageId: MessageId, + action: DraftAction, + scope: CoroutineScope + ) { + syncJob?.cancel() + syncJob = scope.launch(dispatcher) { + draftStateRepository.createOrUpdateLocalState(userId, messageId, action) + while (true) { + delay(SyncInterval) + Timber.d("Draft syncer: syncing draft $messageId") + draftRepository.upload(userId, messageId) + } + } + } + + suspend fun upload(userId: UserId, messageId: MessageId) { + Timber.d("Draft syncer: Forcing upload of draft for $messageId") + draftRepository.forceUpload(userId, messageId) + } + + fun stopContinuousUpload() { + syncJob?.cancel() + } + + companion object { + + val SyncInterval = 1.seconds + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBody.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBody.kt new file mode 100644 index 0000000000..0ecc9ce5c2 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBody.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.key.domain.encryptAndSignText +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.entity.UserAddress +import timber.log.Timber +import javax.inject.Inject + +class EncryptDraftBody @Inject constructor( + private val cryptoContext: CryptoContext, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher +) { + + suspend operator fun invoke(draftBody: DraftBody, senderAddress: UserAddress): Either { + return withContext(defaultDispatcher) { + senderAddress.useKeys(cryptoContext) { + try { + DraftBody(encryptAndSignText(draftBody.value)).right() + } catch (cryptoException: CryptoException) { + Timber.e("Failed to encrypt the message body", cryptoException) + Unit.left() + } + } + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraft.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraft.kt new file mode 100644 index 0000000000..2ef8c2560b --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraft.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import kotlinx.coroutines.flow.first +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class FindLocalDraft @Inject constructor( + private val messageRepository: MessageRepository, + private val draftStateRepository: DraftStateRepository +) { + + + suspend operator fun invoke(userId: UserId, messageId: MessageId): MessageWithBody? { + messageRepository.getLocalMessageWithBody(userId, messageId)?.let { messageFoundByMessageId -> + return messageFoundByMessageId + } + + val apiMessageId = draftStateRepository.observe(userId, messageId).first().onLeft { + Timber.d("No draft state found for $messageId") + }.getOrNull()?.apiMessageId + + apiMessageId?.let { + messageRepository.getLocalMessageWithBody(userId, apiMessageId)?.let { messageFoundByApiMessageId -> + return messageFoundByApiMessageId + } + } + return null + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKey.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKey.kt new file mode 100644 index 0000000000..aa46354ee4 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKey.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.AddressPublicKey +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.extension.primary +import me.proton.core.key.domain.fingerprint +import me.proton.core.key.domain.publicKey +import javax.inject.Inject + +class GetAddressPublicKey @Inject constructor( + private val resolveUserAddress: ResolveUserAddress, + private val cryptoContext: CryptoContext, + @IODispatcher private val dispatcher: CoroutineDispatcher +) { + + suspend operator fun invoke(userId: UserId, email: SenderEmail): Either = + withContext(dispatcher) { + either { + val address = resolveUserAddress(userId, email.value) + .mapLeft { Error.AddressNotFound } + .bind() + + val publicKey = ensureNotNull( + runCatching { address.keys.primary()?.privateKey?.publicKey(cryptoContext) }.getOrNull() + ) { + Error.PublicKeyNotFound + } + val fingerprint = ensureNotNull(runCatching { publicKey.fingerprint(cryptoContext) }.getOrNull()) { + Error.PublicKeyFingerprint + } + + val fingerprintUppercase8Chars = fingerprint.substring(0, 8).uppercase() + val fileName = "publickey - ${address.email} - 0x$fingerprintUppercase8Chars.asc" + val mimeType = "application/pgp-keys" + + AddressPublicKey( + fileName, + mimeType, + publicKey.key.toByteArray() + ) + } + } + + sealed interface Error { + data object AddressNotFound : Error + data object PublicKeyNotFound : Error + data object PublicKeyFingerprint : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddresses.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddresses.kt new file mode 100644 index 0000000000..9237ba3e21 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddresses.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.IsPaidMailUser +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import kotlinx.coroutines.flow.first +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject + +class GetComposerSenderAddresses @Inject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val isPaidUser: IsPaidMailUser, + private val observeUserAddresses: ObserveUserAddresses +) { + + suspend operator fun invoke(): Either> { + val userId = observePrimaryUserId().first() ?: return Error.FailedGettingPrimaryUser.left() + + val isPaidUser = isPaidUser.invoke(userId).getOrElse { + return Error.FailedDeterminingUserSubscription.left() + } + + val addresses = observeUserAddresses(userId).first() + .filter { it.enabled && it.canSend } + + return if (addresses.size < 2 && !isPaidUser) { + Error.UpgradeToChangeSender.left() + } else { + addresses.right() + } + } + + sealed interface Error { + object UpgradeToChangeSender : Error + object FailedDeterminingUserSubscription : Error + object FailedGettingPrimaryUser : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFields.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFields.kt new file mode 100644 index 0000000000..b9a146a482 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFields.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class GetDecryptedDraftFields @Inject constructor( + private val messageRepository: MessageRepository, + private val getDecryptedMessageBody: GetDecryptedMessageBody, + private val splitMessageBodyHtmlQuote: SplitMessageBodyHtmlQuote +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either { + Timber.d("Get decrypted draft data for $userId $messageId") + val refreshedMessageWithBody = messageRepository.getRefreshedMessageWithBody(userId, messageId) + ?: return DataError.Local.NoDataCached.left() + + val decryptedMessageBody = getDecryptedMessageBody(userId, messageId).getOrElse { + return DataError.Local.DecryptionError.left() + } + + val message = refreshedMessageWithBody.messageWithBody.message + val splitMessageBody = splitMessageBodyHtmlQuote(decryptedMessageBody) + + val draftFields = DraftFields( + SenderEmail(message.sender.address), + Subject(message.subject), + splitMessageBody.first, + RecipientsTo(message.toList), + RecipientsCc(message.ccList), + RecipientsBcc(message.bccList), + splitMessageBody.second + ) + + return if (refreshedMessageWithBody.isRefreshed) { + DecryptedDraftFields.Remote(draftFields) + } else { + DecryptedDraftFields.Local(draftFields) + }.right() + } + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipients.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipients.kt new file mode 100644 index 0000000000..4eb2f9552b --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipients.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailmessage.domain.model.Recipient +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.repository.PublicAddressRepository +import me.proton.core.key.domain.repository.getPublicAddressOrNull +import javax.inject.Inject + +class GetExternalRecipients @Inject constructor( + private val publicAddressRepository: PublicAddressRepository +) { + + suspend operator fun invoke( + userId: UserId, + recipientsTo: RecipientsTo, + recipientsCc: RecipientsCc, + recipientsBcc: RecipientsBcc + ): List = (recipientsTo.value + recipientsCc.value + recipientsBcc.value).filter { recipient -> + publicAddressRepository.getPublicAddressOrNull(userId, recipient.address)?.let { publicAddress -> + publicAddress.recipient == me.proton.core.key.domain.entity.key.Recipient.External + } ?: false + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraft.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraft.kt new file mode 100644 index 0000000000..3e422dc031 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraft.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class GetLocalDraft @Inject constructor( + private val createEmptyDraft: CreateEmptyDraft, + private val findLocalDraft: FindLocalDraft, + private val resolveUserAddress: ResolveUserAddress +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail + ): Either = either { + val senderAddress = resolveUserAddress(userId, senderEmail.value) + .mapLeft { Error.ResolveUserAddressError } + .bind() + + return@either findLocalDraft(userId, messageId) + ?: createEmptyDraft(messageId, userId, senderAddress) + } + + sealed interface Error { + object ResolveUserAddressError : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecrypted.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecrypted.kt new file mode 100644 index 0000000000..83df551efd --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecrypted.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class GetLocalMessageDecrypted @Inject constructor( + private val messageRepository: MessageRepository, + private val getDecryptedMessageBody: GetDecryptedMessageBody +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either { + Timber.d("Get decrypted local message data for $userId $messageId") + val messageWithBody = messageRepository.getLocalMessageWithBody(userId, messageId) + if (messageWithBody == null) { + Timber.e("Error getting local message decrypted") + return DataError.Local.NoDataCached.left() + } + + val decryptedMessageBody = getDecryptedMessageBody(userId, messageId).getOrElse { + return DataError.Local.DecryptionError.left() + } + + return MessageWithDecryptedBody(messageWithBody, decryptedMessageBody).right() + } + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/InjectAddressPublicKeyIntoMessage.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/InjectAddressPublicKeyIntoMessage.kt new file mode 100644 index 0000000000..84727b8a53 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/InjectAddressPublicKeyIntoMessage.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import arrow.core.right +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.usecase.ProvideNewAttachmentId +import ch.protonmail.android.mailsettings.domain.usecase.ObserveMailSettings +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class InjectAddressPublicKeyIntoMessage @Inject constructor( + private val attachmentRepository: AttachmentRepository, + private val provideNewAttachmentId: ProvideNewAttachmentId, + private val findLocalDraft: FindLocalDraft, + private val getAddressPublicKey: GetAddressPublicKey, + private val observeMailSettings: ObserveMailSettings +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Either = either { + + if (observeMailSettings(userId).firstOrNull()?.attachPublicKey != true) { + return Unit.right() + } + + val localDraft = ensureNotNull(findLocalDraft(userId, messageId)) { + Error.DraftNotFound + } + + val publicKey = getAddressPublicKey( + userId, + SenderEmail(localDraft.message.sender.address) + ).mapLeft { + Error.GettingAddressPublicKey + }.bind() + + attachmentRepository.createAttachment( + userId, + localDraft.message.messageId, + provideNewAttachmentId(), + publicKey.fileName, + publicKey.mimeType, + publicKey.bytes + ).mapLeft { + Error.CreatingAttachment + }.bind() + } + + sealed interface Error { + object DraftNotFound : Error + object GettingAddressPublicKey : Error + object CreatingAttachment : Error + } + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApi.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApi.kt new file mode 100644 index 0000000000..dee5ea9c69 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApi.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.util.UUID +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import timber.log.Timber +import javax.inject.Inject + +class IsDraftKnownToApi @Inject constructor() { + + operator fun invoke(draftState: DraftState): Boolean = + draftState.apiMessageId != null || hasUuidFormat(draftState.messageId).not() + + + private fun hasUuidFormat(messageId: MessageId) = try { + UUID.fromString(messageId.id) + true + } catch (e: IllegalArgumentException) { + Timber.d("Given messageId ($this) is not a local id (not in UUID format). $e") + false + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddress.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddress.kt new file mode 100644 index 0000000000..6903641a4d --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddress.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import javax.inject.Inject + +@Deprecated("Part of Composer V1, to be replaced with EmailValidator") +class IsValidEmailAddress @Inject constructor() { + + operator fun invoke(emailAddress: String): Boolean { + val regex = EMAIL_VALIDATION_PATTERN.toRegex(RegexOption.IGNORE_CASE) + return emailAddress.isNotBlank() && regex.matches(emailAddress) + } + + private companion object { + + // Taken from core, apparently valid for RFC 5322. Does not impose maximum length restrictions, we will + // rely in the API to give us an error in that case. + @Suppress("MaxLineLength") + const val EMAIL_VALIDATION_PATTERN = + """(?:[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""" + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimistically.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimistically.kt new file mode 100644 index 0000000000..be1da889ba --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimistically.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class MoveToSentOptimistically @Inject constructor( + private val messageRepository: MessageRepository, + private val findLocalDraft: FindLocalDraft +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) { + val localDraft = findLocalDraft(userId, messageId) + val localDraftMessageId = if (localDraft != null) { + localDraft.message.messageId + } else { + Timber.e("Local draft not found while trying to move sending message to sent $messageId") + messageId + } + + messageRepository.moveMessageFromDraftsToSent(userId, localDraftMessageId).onLeft { + Timber.e("Failed moving sending message to sent folder optimistically: $it") + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachments.kt new file mode 100644 index 0000000000..d16b474fe4 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachments.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveMessageAttachments @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val messageRepository: MessageRepository +) { + + operator fun invoke(userId: UserId, messageId: MessageId): Flow> { + return draftStateRepository.observe(userId, messageId) + .distinctUntilChanged() + .flatMapLatest { + val draftState = it.getOrNull() + val currentId = draftState?.apiMessageId ?: messageId + messageRepository.observeMessageAttachments(userId, currentId) + .verifyAssociatedMessageIdForAttachments(userId, currentId) + } + .distinctUntilChanged() + } + + /** + * This method got introduced since we failed to come up with a better solution + * The problem is that when the messageId gets updated, room decides which observer is triggered first + * This is causing that observing the attachments is triggered before the draft state update is emitted + * Since the messageId changed, the attachments are not associated with the used messageId anymore + * To avoid flickering in the UI, this method loads the latest version of the draft state + * and uses the updated apiMessageId, if it exists, to load the attachments + */ + private fun Flow>.verifyAssociatedMessageIdForAttachments( + userId: UserId, + messageId: MessageId + ): Flow> = this.map { + it.ifEmpty { + val currentDraftState = draftStateRepository.observe(userId, messageId).first().getOrNull() + ?: return@map emptyList() + val latestMessageID = currentDraftState.apiMessageId ?: messageId + messageRepository.observeMessageAttachments(userId, latestMessageID).first() + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTime.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTime.kt new file mode 100644 index 0000000000..c8c73682d9 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTime.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveMessageExpirationTime @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val messageExpirationTimeRepository: MessageExpirationTimeRepository, + private val transactor: Transactor +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Flow = + transactor.performTransaction { + draftStateRepository.observe(userId, messageId) + .distinctUntilChanged() + .flatMapLatest { draftStateEither -> + val draftState = draftStateEither.getOrNull() + messageExpirationTimeRepository.observeMessageExpirationTime( + userId, + draftState?.apiMessageId ?: messageId + ) + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePassword.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePassword.kt new file mode 100644 index 0000000000..52b155cd8d --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePassword.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import me.proton.core.crypto.common.keystore.KeyStoreCrypto +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveMessagePassword @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val keyStoreCrypto: KeyStoreCrypto, + private val messagePasswordRepository: MessagePasswordRepository, + private val transactor: Transactor +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId): Flow = + transactor.performTransaction { + draftStateRepository.observe(userId, messageId) + .distinctUntilChanged() + .flatMapLatest { draftStateEither -> + val draftState = draftStateEither.getOrNull() + messagePasswordRepository.observeMessagePassword( + userId, draftState?.apiMessageId ?: messageId + ).mapLatest { messagePassword -> + if (messagePassword == null) return@mapLatest null + + return@mapLatest runCatching { + keyStoreCrypto.decrypt(messagePassword.password) + }.fold( + onSuccess = { it }, + onFailure = { null } + )?.let { messagePassword.copy(password = it) } + } + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageSendingError.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageSendingError.kt new file mode 100644 index 0000000000..cdd2312063 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageSendingError.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.transformLatest +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveMessageSendingError @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + operator fun invoke(userId: UserId, messageId: MessageId) = + draftStateRepository.observe(userId, messageId).transformLatest { draftState -> + draftState.getOrNull()?.sendingError?.let { sendingError -> + emit(sendingError) + } + }.distinctUntilChanged() + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatus.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatus.kt new file mode 100644 index 0000000000..3dab219bf5 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatus.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveSendingMessagesStatus @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + operator fun invoke(userId: UserId) = draftStateRepository.observeAll(userId).map { draftStates -> + val unconfirmedDraftStates = draftStates.filter { !it.sendingStatusConfirmed } + + if (unconfirmedDraftStates.any { it.state == DraftSyncState.ErrorSending }) { + return@map MessageSendingStatus.SendMessageError + } + + if (unconfirmedDraftStates.any { it.state == DraftSyncState.ErrorUploadAttachments }) { + return@map MessageSendingStatus.UploadAttachmentsError + } + + if (unconfirmedDraftStates.any { it.state == DraftSyncState.Sent }) { + return@map MessageSendingStatus.MessageSent + } + + MessageSendingStatus.None + }.distinctUntilChanged() + +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBody.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBody.kt new file mode 100644 index 0000000000..d5343f0640 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBody.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Sender +import ch.protonmail.android.mailmessage.domain.usecase.ConvertPlainTextIntoHtml +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import timber.log.Timber +import javax.inject.Inject + +class PrepareAndEncryptDraftBody @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val resolveUserAddress: ResolveUserAddress, + private val convertPlainTextIntoHtml: ConvertPlainTextIntoHtml, + private val encryptDraftBody: EncryptDraftBody +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + draftBody: DraftBody, + quotedHtmlBody: OriginalHtmlQuote?, + senderEmail: SenderEmail + ): Either = either { + + val senderAddress = resolveUserAddress(userId, senderEmail.value) + .mapLeft { PrepareDraftBodyError.DraftResolveUserAddressError } + .bind() + + val draftWithBody = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { PrepareDraftBodyError.DraftReadError } + .bind() + + val isOriginalMimeHtml = draftWithBody.messageBody.mimeType == MimeType.Html + val hasQuotedBody = quotedHtmlBody != null + val updatedDraftBody = if (isOriginalMimeHtml || hasQuotedBody) { + draftBody.convertToHtml() + } else { + draftBody + } + + val encryptedDraftBody = encryptDraftBody(updatedDraftBody.appendQuotedHtml(quotedHtmlBody), senderAddress) + .mapLeft { + Timber.e("Encrypt draft $messageId body to store to local DB failed") + PrepareDraftBodyError.DraftBodyEncryptionError + } + .bind() + + val updatedDraft = draftWithBody + .updateWith(senderAddress, encryptedDraftBody) + .updateMimeWhenQuotingHtml(quotedHtmlBody) + + updatedDraft + } + + private fun DraftBody.convertToHtml() = DraftBody( + value = convertPlainTextIntoHtml(this.value, autoTransformLinks = false) + ) + + private fun DraftBody.appendQuotedHtml(quotedHtmlBody: OriginalHtmlQuote?) = + quotedHtmlBody?.let { quotedHtml -> DraftBody("${this.value}${quotedHtml.value}") } ?: this + + private fun MessageWithBody.updateMimeWhenQuotingHtml(quotedHtmlBody: OriginalHtmlQuote?): MessageWithBody { + if (quotedHtmlBody == null) { + return this + } + + return this.copy(messageBody = this.messageBody.copy(mimeType = MimeType.Html)) + } + + private fun MessageWithBody.updateWith(senderAddress: UserAddress, encryptedDraftBody: DraftBody) = this.copy( + message = this.message.copy( + sender = Sender(senderAddress.email, senderAddress.displayName.orEmpty()), + addressId = senderAddress.addressId + ), + messageBody = this.messageBody.copy( + body = encryptedDraftBody.value + ) + ) +} + +sealed interface PrepareDraftBodyError { + data object DraftBodyEncryptionError : PrepareDraftBodyError + data object DraftReadError : PrepareDraftBodyError + data object DraftResolveUserAddressError : PrepareDraftBodyError +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftId.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftId.kt new file mode 100644 index 0000000000..5ce6d85a6f --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftId.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.util.UUID +import ch.protonmail.android.mailmessage.domain.model.MessageId +import javax.inject.Inject + +class ProvideNewDraftId @Inject constructor() { + + operator fun invoke(): MessageId = MessageId(UUID.randomUUID().toString()) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachments.kt new file mode 100644 index 0000000000..91120013cf --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachments.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcommon.domain.util.mapFalse +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.decryptSessionKey +import me.proton.core.key.domain.encryptSessionKey +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.entity.AddressId +import timber.log.Timber +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +class ReEncryptAttachments @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val messageRepository: MessageRepository, + private val resolveUserAddress: ResolveUserAddress, + private val cryptoContext: CryptoContext, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + previousSender: SenderEmail, + newSenderEmail: SenderEmail + ): Either = transactor.performTransaction { + Timber.d("Re encrypting attachments - $previousSender -> $newSenderEmail") + either { + val draft = getLocalDraft(userId, messageId, newSenderEmail) + .mapLeft { AttachmentReEncryptionError.DraftNotFound } + .bind() + + draft.messageBody.attachments + .mapNotNull { + if (it.keyPackets.isNullOrEmpty().not()) + AttachmentKeyPacket(it.attachmentId, it.keyPackets!!) + else + null + } + .takeIf { it.isNotEmpty() } + ?.let { decryptKeyPackets(userId, previousSender, it).bind() } + ?.let { encryptKeyPackets(userId, draft.message.addressId, it).bind() } + ?.let { updateAttachmentKeyPackets(userId, draft, it).bind() } + } + } + + private suspend fun updateAttachmentKeyPackets( + userId: UserId, + message: MessageWithBody, + reEncryptedKeyPackets: List + ): Either { + val updatedAttachments = message.messageBody.attachments + .map { oldAttachment -> + val reEncryptedKeyPackets = reEncryptedKeyPackets.firstOrNull { + it.attachmentId == oldAttachment.attachmentId + }?.keyPacket + ?: return AttachmentReEncryptionError.FailedToUpdateAttachmentKeyPackets.left() + + oldAttachment.copy(keyPackets = reEncryptedKeyPackets) + } + + val updatedMessage = message.copy( + messageBody = message.messageBody.copy( + attachments = updatedAttachments + ) + ) + + return messageRepository.upsertMessageWithBody(userId, updatedMessage) + .mapFalse { AttachmentReEncryptionError.FailedToUpdateAttachmentKeyPackets } + .fold( + ifLeft = { AttachmentReEncryptionError.FailedToUpdateAttachmentKeyPackets.left() }, + ifRight = { true.right() } + ) + } + + private suspend fun encryptKeyPackets( + userId: UserId, + newAddressId: AddressId, + attachmentIdWithSessionKey: List + ): Either> = resolveUserAddress(userId, newAddressId) + .mapLeft { AttachmentReEncryptionError.FailedToResolveNewUserAddress } + .map { userAddress -> + userAddress.useKeys(cryptoContext) { + return@map attachmentIdWithSessionKey.map { + try { + AttachmentKeyPacket(it.attachmentId, Base64.encode(encryptSessionKey(it.sessionKey))) + } catch (e: CryptoException) { + Timber.e(e, "Failed to encrypt attachment key packet") + return AttachmentReEncryptionError.FailedToEncryptAttachmentKeyPackets.left() + } + } + } + } + + private suspend fun decryptKeyPackets( + userId: UserId, + oldAddressId: SenderEmail, + attachmentIdWithKeyPackets: List + ): Either> = resolveUserAddress(userId, oldAddressId.value) + .mapLeft { AttachmentReEncryptionError.FailedToResolvePreviousUserAddress } + .map { userAddress -> + userAddress.useKeys(cryptoContext) { + return@map attachmentIdWithKeyPackets.map { + try { + AttachmentSessionKey(it.attachmentId, decryptSessionKey(Base64.decode(it.keyPacket))) + } catch (e: CryptoException) { + Timber.e(e, "Failed to decrypt attachment key packet") + return AttachmentReEncryptionError.FailedToDecryptAttachmentKeyPackets.left() + } + } + } + } + + private data class AttachmentKeyPacket(val attachmentId: AttachmentId, val keyPacket: String) + private data class AttachmentSessionKey(val attachmentId: AttachmentId, val sessionKey: SessionKey) +} + +sealed interface AttachmentReEncryptionError { + object DraftNotFound : AttachmentReEncryptionError + object FailedToResolvePreviousUserAddress : AttachmentReEncryptionError + object FailedToResolveNewUserAddress : AttachmentReEncryptionError + object FailedToDecryptAttachmentKeyPackets : AttachmentReEncryptionError + object FailedToEncryptAttachmentKeyPackets : AttachmentReEncryptionError + object FailedToUpdateAttachmentKeyPackets : AttachmentReEncryptionError +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateError.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateError.kt new file mode 100644 index 0000000000..b2960f78b9 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateError.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ResetDraftStateError @Inject constructor( + private val draftStateRepository: DraftStateRepository +) { + + suspend operator fun invoke(userId: UserId, messageId: MessageId) { + draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Synchronized) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatus.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatus.kt new file mode 100644 index 0000000000..2e959f7c24 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatus.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ResetSendingMessagesStatus @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val resetDraftStateError: ResetDraftStateError, + private val confirmSendingMessageStatus: ConfirmSendingMessageStatus +) { + + suspend operator fun invoke(userId: UserId) { + draftStateRepository.observeAll(userId).firstOrNull()?.map { + if (it.state == DraftSyncState.ErrorSending || it.state == DraftSyncState.ErrorUploadAttachments) { + resetDraftStateError(it.userId, it.messageId) + confirmSendingMessageStatus(it.userId, it.messageId) + } else if (it.state == DraftSyncState.Sent) { + confirmSendingMessageStatus(it.userId, it.messageId) + } + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraft.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraft.kt new file mode 100644 index 0000000000..017b89139e --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraft.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class SaveDraft @Inject constructor( + private val messageRepository: MessageRepository +) { + + suspend operator fun invoke(messageWithBody: MessageWithBody, userId: UserId): Boolean = + messageRepository.upsertMessageWithBody(userId, messageWithBody) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTime.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTime.kt new file mode 100644 index 0000000000..a22b673d56 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTime.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration + +class SaveMessageExpirationTime @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val messageExpirationTimeRepository: MessageExpirationTimeRepository, + private val messageRepository: MessageRepository, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + expiresIn: Duration + ): Either = transactor.performTransaction { + either { + val draft = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { DataError.Local.NoDataCached } + .bind() + // Verify that draft exists in db, if not create it + val messageWithBody = messageRepository.getLocalMessageWithBody(userId, draft.message.messageId) + if (messageWithBody == null) { + val success = saveDraft(draft, userId) + if (!success) { + Timber.d("Failed to save draft") + raise(DataError.Local.Unknown) + } + } + + val messageExpirationTime = MessageExpirationTime(userId, draft.message.messageId, expiresIn) + messageExpirationTimeRepository.saveMessageExpirationTime(messageExpirationTime).bind() + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePassword.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePassword.kt new file mode 100644 index 0000000000..101cb58022 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePassword.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import me.proton.core.crypto.common.keystore.KeyStoreCrypto +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class SaveMessagePassword @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val keyStoreCrypto: KeyStoreCrypto, + private val messagePasswordRepository: MessagePasswordRepository, + private val messageRepository: MessageRepository, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + password: String, + passwordHint: String?, + action: SaveMessagePasswordAction = SaveMessagePasswordAction.Create + ): Either = transactor.performTransaction { + either { + val draft = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { DataError.Local.NoDataCached } + .bind() + // Verify that draft exists in db, if not create it + val messageWithBody = messageRepository.getLocalMessageWithBody(userId, draft.message.messageId) + if (messageWithBody == null) { + val success = saveDraft(draft, userId) + if (!success) { + Timber.d("Failed to save draft") + raise(DataError.Local.Unknown) + } + } + + val encryptedPassword = runCatching { keyStoreCrypto.encrypt(password) }.fold( + onSuccess = { it }, + onFailure = { raise(DataError.Local.EncryptionError) } + ) + + if (action == SaveMessagePasswordAction.Create) { + messagePasswordRepository.saveMessagePassword( + MessagePassword(userId, draft.message.messageId, encryptedPassword, passwordHint) + ) + } else { + messagePasswordRepository.updateMessagePassword( + userId, draft.message.messageId, encryptedPassword, passwordHint + ) + }.bind() + } + } +} + +enum class SaveMessagePasswordAction { Create, Update } diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SendMessage.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SendMessage.kt new file mode 100644 index 0000000000..668b983f6b --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SendMessage.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class SendMessage @Inject constructor( + private val storeDraftWithAllFields: StoreDraftWithAllFields, + private val messageRepository: MessageRepository, + private val draftStateRepository: DraftStateRepository, + private val moveToSentOptimistically: MoveToSentOptimistically, + private val injectAddressPublicKeyIntoMessage: InjectAddressPublicKeyIntoMessage, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + fields: DraftFields, + action: DraftAction = DraftAction.Compose + ) { + transactor.performTransaction { + storeDraftWithAllFields( + userId, + messageId, + fields, + action + ).onLeft { + Timber.e("SendMessage: failed to store draft with all fields: $it") + } + + draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Sending).onLeft { + Timber.e("SendMessage: error updating draft sync state: $it") + } + + moveToSentOptimistically(userId, messageId) + injectAddressPublicKeyIntoMessage(userId, messageId).onLeft { + Timber.e("SendMessage: error injecting public key: $it") + } + } + messageRepository.send(userId, messageId) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SplitMessageBodyHtmlQuote.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SplitMessageBodyHtmlQuote.kt new file mode 100644 index 0000000000..3904685252 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SplitMessageBodyHtmlQuote.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import androidx.core.text.HtmlCompat +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import org.jsoup.Jsoup +import javax.inject.Inject + +class SplitMessageBodyHtmlQuote @Inject constructor() { + + operator fun invoke(decryptedBody: DecryptedMessageBody): Pair { + if (decryptedBody.mimeType == MimeType.PlainText) { + return Pair(DraftBody(decryptedBody.value), null) + } + + val htmlBodyDocument = Jsoup.parse(decryptedBody.value) + var htmlQuote: String? = null + + for (quoteAnchor in QuoteAnchors) { + val quotedContentElements = htmlBodyDocument.select(quoteAnchor) + if (quotedContentElements.isNotEmpty()) { + htmlQuote = quotedContentElements[0].toString() + // Removes the quoted content from htmlBodyDocument + quotedContentElements.remove() + } + } + + val bodyContent = HtmlCompat.fromHtml( + htmlBodyDocument.body().html(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_DIV + ).toString() + + val draftBody = DraftBody(bodyContent) + val draftQuote = htmlQuote?.let { OriginalHtmlQuote(it) } + return Pair(draftBody, draftQuote) + } + + companion object { + + private val QuoteAnchors = listOf( + ".protonmail_quote", + ".gmail_quote", + ".yahoo_quoted", + ".gmail_extra", + ".zmail_extra", // zoho + ".moz-cite-prefix", + "#isForwardContent", + "#isReplyContent", + "#mailcontent:not(table)", + "#origbody", + "#reply139content", + "#oriMsgHtmlSeperator", + "blockquote[type=\"cite\"]", + "[name=\"quote\"]" // gmx + ) + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachments.kt new file mode 100644 index 0000000000..5c5553da6e --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachments.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import android.net.Uri +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.usecase.ProvideNewAttachmentId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class StoreAttachments @Inject constructor( + private val messageRepository: MessageRepository, + private val attachmentRepository: AttachmentRepository, + private val attachmentStateRepository: AttachmentStateRepository, + private val getLocalDraft: GetLocalDraft, + private val saveDraft: SaveDraft, + private val provideNewAttachmentId: ProvideNewAttachmentId, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + uriList: List + ): Either = transactor.performTransaction { + either { + if (uriList.isEmpty()) { + shift(StoreDraftWithAttachmentError.AttachmentsMissing) + } + + val draft = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { StoreDraftWithAttachmentError.FailedReceivingDraft } + .bind() + + // Verify that draft exists in db, if not create it + val messageWithBody = messageRepository.getLocalMessageWithBody(userId, draft.message.messageId) + Timber.d("Draft exists in db: ${messageWithBody != null}") + if (messageWithBody == null) { + val success = saveDraft(draft, userId) + if (!success) { + Timber.d("Failed to save draft") + shift(StoreDraftWithAttachmentError.FailedReceivingDraft) + } + } + var attachmentFailedToStore = false + var sumAttachmentSize = messageWithBody?.messageBody?.attachments?.sumOf { it.size } ?: 0L + uriList.forEach { + val fileSize = attachmentRepository.getFileSizeFromUri(it) + .mapLeft { StoreDraftWithAttachmentError.AttachmentFileMissing } + .bind() + sumAttachmentSize += fileSize + if (sumAttachmentSize > MAX_ATTACHMENTS_SIZE) { + shift(StoreDraftWithAttachmentError.FileSizeExceedsLimit) + } + val attachmentId = provideNewAttachmentId() + attachmentRepository.saveAttachment(userId, draft.message.messageId, attachmentId, it) + .onLeft { attachmentFailedToStore = true } + .onRight { + attachmentStateRepository.createOrUpdateLocalState( + userId, draft.message.messageId, attachmentId + ) + } + } + if (attachmentFailedToStore) { + shift(StoreDraftWithAttachmentError.FailedToStoreAttachments) + } + } + } + + companion object { + + const val MAX_ATTACHMENTS_SIZE = 25 * 1000 * 1000L + } +} + +sealed interface StoreDraftWithAttachmentError { + object AttachmentsMissing : StoreDraftWithAttachmentError + object FailedReceivingDraft : StoreDraftWithAttachmentError + object FailedToStoreAttachments : StoreDraftWithAttachmentError + object FileSizeExceedsLimit : StoreDraftWithAttachmentError + object AttachmentFileMissing : StoreDraftWithAttachmentError +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFields.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFields.kt new file mode 100644 index 0000000000..efe5694b3e --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFields.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.raise.either +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class StoreDraftWithAllFields @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val prepareAndEncryptDraftBody: PrepareAndEncryptDraftBody, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + draftMessageId: MessageId, + fields: DraftFields, + action: DraftAction = DraftAction.Compose + ) = either { + transactor.performTransaction { + val draftWithBody = prepareAndEncryptDraftBody( + userId, draftMessageId, fields.body, fields.originalHtmlQuote, fields.sender + ).bind() + + val updatedDraft = draftWithBody.copy( + message = draftWithBody.message.copy( + subject = fields.subject.value, + toList = fields.recipientsTo.value, + ccList = fields.recipientsCc.value, + bccList = fields.recipientsBcc.value + ) + ) + saveDraft(updatedDraft, userId) + + draftStateRepository.createOrUpdateLocalState(userId, draftMessageId, action) + } + Timber.d("Draft: finished storing draft locally $draftMessageId") + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBody.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBody.kt new file mode 100644 index 0000000000..24c5e927a5 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBody.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.util.mapFalse +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +@Deprecated("Part of Composer V1, to be removed") +class StoreDraftWithBody @Inject constructor( + private val prepareAndEncryptDraftBody: PrepareAndEncryptDraftBody, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + draftBody: DraftBody, + quotedHtmlBody: OriginalHtmlQuote?, + senderEmail: SenderEmail + ): Either = either { + transactor.performTransaction { + val updatedDraft = prepareAndEncryptDraftBody(userId, messageId, draftBody, quotedHtmlBody, senderEmail) + .mapLeft { + Timber.e("Prepare encrypted $messageId body failed") + StoreDraftWithBodyError.DraftSaveError + } + .bind() + + saveDraft(updatedDraft, userId) + .mapFalse { + Timber.e("Store draft $messageId body to local DB failed") + StoreDraftWithBodyError.DraftSaveError + } + .bind() + } + } +} + +@Deprecated("Part of Composer V1, to be removed") +sealed interface StoreDraftWithBodyError { + data object DraftBodyEncryptionError : StoreDraftWithBodyError + data object DraftSaveError : StoreDraftWithBodyError + data object DraftReadError : StoreDraftWithBodyError + data object DraftResolveUserAddressError : StoreDraftWithBodyError +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachments.kt new file mode 100644 index 0000000000..d838533e1f --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachments.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.util.mapFalse +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class StoreDraftWithParentAttachments @Inject constructor( + private val attachmentRepository: AttachmentRepository, + private val deleteAllAttachments: DeleteAllAttachments, + private val getLocalDraft: GetLocalDraft, + private val saveDraft: SaveDraft, + private val storeParentAttachmentStates: StoreParentAttachmentStates, + private val transactor: Transactor +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + parentMessage: MessageWithDecryptedBody, + senderEmail: SenderEmail, + draftAction: DraftAction + ): Either = either { + transactor.performTransaction { + + val parentAttachments = getParentAttachments(draftAction, parentMessage.decryptedMessageBody.attachments) + if (parentAttachments.isEmpty()) { + Timber.d("No attachments to be stored from parent message") + raise(Error.NoAttachmentsToBeStored) + } + + Timber.d("Storing draft for action: $draftAction with parent attachments: $parentAttachments") + saveDraftWithParentAttachments(userId, messageId, senderEmail, parentAttachments) + + val parentAttachmentIds = parentAttachments.map { it.attachmentId } + + if (parentMessage.messageWithBody.messageBody.mimeType == MimeType.MultipartMixed) { + copyParentAttachmentsToDraft( + userId = userId, + senderEmail = senderEmail, + parentMessageId = parentMessage.decryptedMessageBody.messageId, + draftMessageId = messageId, + parentAttachmentIds = parentAttachmentIds + ) + } + + storeParentAttachmentSyncStates( + userId = userId, + messageId = messageId, + parentMessageMimeType = parentMessage.messageWithBody.messageBody.mimeType, + parentAttachmentIds = parentAttachmentIds + ) + } + } + + private fun Raise.getParentAttachments( + draftAction: DraftAction, + parentMessageAttachments: List + ): List = when (draftAction) { + is DraftAction.PrefillForShare -> emptyList() + is DraftAction.Forward -> parentMessageAttachments + is DraftAction.Reply, + is DraftAction.ReplyAll -> parentMessageAttachments.filter { it.disposition == "inline" } + + is DraftAction.Compose, + is DraftAction.ComposeToAddresses -> { + Timber.w("Store Draft with parent attachments for a Compose action. This shouldn't happen.") + raise(Error.ActionWithNoParent) + } + } + + private suspend fun Raise.saveDraftWithParentAttachments( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + parentAttachments: List + ) { + val draftWithBody = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { Error.DraftDataError } + .bind() + val parentAttachmentsWithoutSignature = parentAttachments.map { it.copy(signature = null, encSignature = null) } + val updatedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy( + attachments = draftWithBody.messageBody.attachments + parentAttachmentsWithoutSignature + ) + ) + saveDraft(updatedDraft, userId) + .mapFalse { Error.DraftDataError } + .bind() + } + + private suspend fun Raise.copyParentAttachmentsToDraft( + userId: UserId, + senderEmail: SenderEmail, + parentMessageId: MessageId, + draftMessageId: MessageId, + parentAttachmentIds: List + ) { + attachmentRepository.copyMimeAttachmentsToMessage( + userId = userId, + sourceMessageId = parentMessageId, + targetMessageId = draftMessageId, + attachmentIds = parentAttachmentIds + ).onLeft { + deleteAllAttachments(userId, senderEmail, draftMessageId) + raise(Error.DraftAttachmentError) + } + } + + private suspend fun Raise.storeParentAttachmentSyncStates( + userId: UserId, + messageId: MessageId, + parentMessageMimeType: MimeType, + parentAttachmentIds: List + ) { + val syncState = if (parentMessageMimeType == MimeType.MultipartMixed) { + AttachmentSyncState.Local + } else { + AttachmentSyncState.External + } + storeParentAttachmentStates(userId, messageId, parentAttachmentIds, syncState) + .mapLeft { Error.DraftAttachmentError } + .bind() + } + + sealed interface Error { + object DraftDataError : Error + object DraftAttachmentError : Error + object ActionWithNoParent : Error + object NoAttachmentsToBeStored : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipients.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipients.kt new file mode 100644 index 0000000000..043df13e0c --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipients.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.util.mapFalse +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Recipient +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +@Deprecated("Part of Composer V1, to be removed") +class StoreDraftWithRecipients @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + to: List? = null, + cc: List? = null, + bcc: List? = null + ): Either = either { + transactor.performTransaction { + val draftWithBody = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { Error.DraftReadError } + .bind() + + val updatedDraft = draftWithBody.copy( + message = draftWithBody.message.copy( + toList = to ?: draftWithBody.message.toList, + ccList = cc ?: draftWithBody.message.ccList, + bccList = bcc ?: draftWithBody.message.bccList + ) + ) + saveDraft(updatedDraft, userId) + .mapFalse { Error.DraftSaveError } + .bind() + } + } + + @Deprecated("Part of Composer V1, to be removed") + sealed interface Error { + object DraftSaveError : Error + object DraftReadError : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubject.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubject.kt new file mode 100644 index 0000000000..6b4f336b73 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubject.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.util.mapFalse +import ch.protonmail.android.mailcomposer.domain.Transactor +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +@Deprecated("Part of Composer V1, to be removed") +class StoreDraftWithSubject @Inject constructor( + private val getLocalDraft: GetLocalDraft, + private val saveDraft: SaveDraft, + private val transactor: Transactor +) { + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + subject: Subject + ): Either = either { + transactor.performTransaction { + val draftWithBody = getLocalDraft(userId, messageId, senderEmail) + .mapLeft { Error.DraftReadError } + .bind() + + val updatedDraft = draftWithBody.copy( + message = draftWithBody.message.copy(subject = subject.value) + ) + saveDraft(updatedDraft, userId) + .mapFalse { Error.DraftSaveError } + .bind() + } + } + + sealed interface Error { + object DraftSaveError : Error + object DraftReadError : Error + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachments.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachments.kt new file mode 100644 index 0000000000..e7a8e649a7 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachments.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class StoreExternalAttachments @Inject constructor( + private val messageRepository: MessageRepository, + private val attachmentStateRepository: AttachmentStateRepository +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + syncState: AttachmentSyncState = AttachmentSyncState.ExternalUploaded + ) { + val messageBody = messageRepository.getMessageWithBody(userId, messageId).getOrNull()?.messageBody + + if (messageBody == null) { + Timber.e("Failed to get message with body") + return + } + + messageBody.attachments.let { attachments -> + val states = attachmentStateRepository.getAllAttachmentStatesForMessage(userId, messageId) + attachments + .filterNot { attachment -> attachment.attachmentId in states.map { it.attachmentId } } + .takeIf { it.isNotEmpty() } + ?.map { it.attachmentId } + ?.let { attachmentIds -> + attachmentStateRepository.createOrUpdateLocalStates(userId, messageId, attachmentIds, syncState) + .onLeft { Timber.e("Failed to create or update local states: $it") } + } + } + + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStates.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStates.kt new file mode 100644 index 0000000000..1450f304e7 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStates.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class StoreParentAttachmentStates @Inject constructor( + private val attachmentStateRepository: AttachmentStateRepository +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + attachmentIds: List, + syncState: AttachmentSyncState + ): Either = + attachmentStateRepository.createOrUpdateLocalStates(userId, messageId, attachmentIds, syncState) +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForError.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForError.kt new file mode 100644 index 0000000000..70c72c5c95 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForError.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +/** + * Updates the [DraftState] for the given messageId. + * When any error happens while a message is being sent, state is updated to "error sending", + * [sendingError] persisted and the message is moved back to drafts folder. + * In all other cases, the given newState is applied. + */ +class UpdateDraftStateForError @Inject constructor( + private val draftStateRepository: DraftStateRepository, + private val messageRepository: MessageRepository +) { + + suspend operator fun invoke( + userId: UserId, + messageId: MessageId, + newState: DraftSyncState, + sendingError: SendingError? = null + ) { + val draftState = draftStateRepository.observe(userId, messageId).firstOrNull()?.getOrNull() + + val isMessageAlreadySent = sendingError == SendingError.MessageAlreadySent + val isMessageSending = draftState?.state == DraftSyncState.Sending + + when { + isMessageAlreadySent -> { + draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Sent) + } + isMessageSending -> { + draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.ErrorSending) + draftStateRepository.updateSendingError(userId, messageId, sendingError) + draftState?.apiMessageId?.let { messageRepository.moveMessageBackFromSentToDrafts(userId, it) } + ?: messageRepository.moveMessageBackFromSentToDrafts(userId, messageId) + } + else -> { + draftStateRepository.updateDraftSyncState(userId, messageId, newState) + if (newState == DraftSyncState.ErrorSending) { + draftStateRepository.updateSendingError(userId, messageId, sendingError) + } + } + } + } +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddress.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddress.kt new file mode 100644 index 0000000000..a7f2554874 --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddress.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.IsPaidUser +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject + +class ValidateSenderAddress @Inject constructor( + private val observeUserAddresses: ObserveUserAddresses, + private val isPaidUser: IsPaidUser +) { + + suspend operator fun invoke(userId: UserId, senderEmail: SenderEmail): Either { + val userAddresses = observeUserAddresses(userId).firstOrNull() + ?: return ValidationFailure.CouldNotValidate.left() + + val addressToValidate = userAddresses + .firstOrNull { it.email == senderEmail.value } + ?: return ValidationFailure.CouldNotValidate.left() + + val validAddress = userAddresses.filter { it.enabled }.minByOrNull { it.order } + ?: return ValidationFailure.AllAddressesDisabled.left() + + return when { + !addressToValidate.enabled -> ValidationResult.Invalid( + SenderEmail(validAddress.email), SenderEmail(addressToValidate.email), ValidationError.DisabledAddress + ) + + isFreeUserUsingPmMeAddress(userId, addressToValidate) -> ValidationResult.Invalid( + SenderEmail(validAddress.email), SenderEmail(addressToValidate.email), ValidationError.PaidAddress + ) + + else -> ValidationResult.Valid(SenderEmail(addressToValidate.email)) + }.right() + } + + private suspend fun isFreeUserUsingPmMeAddress(userId: UserId, address: UserAddress) = + !isPaidUser(userId).getOrElse { false } && address.email.endsWith(PmMeDomain, true) + + sealed interface ValidationFailure { + object CouldNotValidate : ValidationFailure + object AllAddressesDisabled : ValidationFailure + } + + sealed interface ValidationResult { + val validAddress: SenderEmail + + data class Valid(override val validAddress: SenderEmail) : ValidationResult + + data class Invalid( + override val validAddress: SenderEmail, + val invalid: SenderEmail, + val reason: ValidationError + ) : ValidationResult + + } + + enum class ValidationError { + DisabledAddress, + PaidAddress, + GenericError + } + + companion object { + private const val PmMeDomain = "@pm.me" + } +} + +fun ValidateSenderAddress.ValidationResult.isInvalidDueToPaidAddress() = when (this) { + is ValidateSenderAddress.ValidationResult.Invalid -> + this.reason == ValidateSenderAddress.ValidationError.PaidAddress + is ValidateSenderAddress.ValidationResult.Valid -> false +} + +fun ValidateSenderAddress.ValidationResult.isInvalidDueToDisabledAddress() = when (this) { + is ValidateSenderAddress.ValidationResult.Invalid -> + this.reason == ValidateSenderAddress.ValidationError.DisabledAddress + is ValidateSenderAddress.ValidationResult.Valid -> false +} diff --git a/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/featureflags/IsComposerV2FeatureEnabled.kt b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/featureflags/IsComposerV2FeatureEnabled.kt new file mode 100644 index 0000000000..677e25ce0e --- /dev/null +++ b/mail-composer/domain/src/main/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/featureflags/IsComposerV2FeatureEnabled.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase.featureflags + +import me.proton.core.featureflag.domain.ExperimentalProtonFeatureFlag +import me.proton.core.featureflag.domain.FeatureFlagManager +import me.proton.core.featureflag.domain.entity.FeatureId +import javax.inject.Inject + +class IsComposerV2FeatureEnabled @Inject constructor( + private val featureFlagManager: FeatureFlagManager +) { + + @OptIn(ExperimentalProtonFeatureFlag::class) + operator fun invoke() = featureFlagManager.getValue(null, FeatureId(FeatureFlagId)) + + private companion object { + + const val FeatureFlagId = "MailAndroidComposerV2" + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatusTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatusTest.kt new file mode 100644 index 0000000000..011dff15d2 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ConfirmSendingMessageStatusTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ConfirmSendingMessageStatusTest { + private val draftStateRepository = mockk() + private val confirmSendingMessageStatus = ConfirmSendingMessageStatus(draftStateRepository) + + @Test + fun `confirm sending status sets sendingStatusConfirmed to true`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = DraftStateSample.RemoteDraftInSentState.messageId + coJustRun { draftStateRepository.updateConfirmDraftSendingStatus(userId, messageId, any()) } + + // When + confirmSendingMessageStatus.invoke(userId, messageId) + + // Then + coVerify { draftStateRepository.updateConfirmDraftSendingStatus(userId, messageId, true) } + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraftTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraftTest.kt new file mode 100644 index 0000000000..eb3b3c6a7b --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/CreateEmptyDraftTest.kt @@ -0,0 +1,104 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.time.Instant +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.mailmessage.domain.model.AttachmentCount +import ch.protonmail.android.mailmessage.domain.model.Message +import ch.protonmail.android.mailmessage.domain.model.MessageBody +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.model.Sender +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import me.proton.core.util.kotlin.EMPTY_STRING +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class CreateEmptyDraftTest { + + @Before + fun setUp() { + mockkStatic(Instant::class) + } + + @Test + fun `should create an empty draft with the current timestamp and the given sender`() { + // Given + val expectedCurrentTimestamp = expectedCurrentTimestamp { 42L } + val expectedMessageId = MessageIdSample.EmptyDraft + val expectedUserId = UserIdSample.Primary + val expectedUserAddress = UserAddressSample.build() + val expectedEmptyDraft = MessageWithBody( + message = Message( + userId = expectedUserId, + messageId = expectedMessageId, + conversationId = ConversationId(EMPTY_STRING), + order = 0, + subject = EMPTY_STRING, + unread = false, + sender = Sender(expectedUserAddress.email, expectedUserAddress.displayName!!), + toList = emptyList(), + ccList = emptyList(), + bccList = emptyList(), + time = expectedCurrentTimestamp, + size = 0L, + expirationTime = 0L, + isReplied = false, + isRepliedAll = false, + isForwarded = false, + addressId = expectedUserAddress.addressId, + externalId = null, + numAttachments = 0, + flags = 0L, + attachmentCount = AttachmentCount(0), + labelIds = listOf( + SystemLabelId.Drafts.labelId, + SystemLabelId.AllDrafts.labelId, + SystemLabelId.AllMail.labelId + ) + ), + messageBody = MessageBody( + userId = expectedUserId, + messageId = expectedMessageId, + body = EMPTY_STRING, + header = EMPTY_STRING, + attachments = emptyList(), + mimeType = MimeType.PlainText, + spamScore = EMPTY_STRING, + replyTo = Recipient( + address = expectedUserAddress.email, + name = expectedUserAddress.displayName!!, + group = null + ), + replyTos = emptyList(), + unsubscribeMethods = null + ) + ) + + // When + val actualEmptyDraft = CreateEmptyDraft()(expectedMessageId, expectedUserId, expectedUserAddress) + + // Then + assertEquals(expectedEmptyDraft, actualEmptyDraft) + } + + @After + fun tearDown() { + unmockkStatic(Instant::class) + } + + private fun expectedCurrentTimestamp(expectedCurrentTimestamp: () -> Long): Long = expectedCurrentTimestamp().also { + every { Instant.now() } returns mockk { + every { epochSecond } returns it + } + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachmentsTest.kt new file mode 100644 index 0000000000..a775fe5f02 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAllAttachmentsTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class DeleteAllAttachmentsTest { + + private val userId = UserIdSample.Primary + private val senderEmail = SenderEmail("senderEmail") + private val messageId = MessageIdSample.MessageWithAttachments + + private val getLocalDraft = mockk() + private val attachmentRepository = mockk() + + private val deleteAllAttachments = DeleteAllAttachments(getLocalDraft, attachmentRepository) + + @Test + fun `when delete all attachment is called, then the repository is called for all attachments`() = runTest { + // Given + expectGetLocalDraftSucceeds() + MessageWithBodySample.MessageWithAttachments.messageBody.attachments.forEach { + expectAttachmentRepositoryDeleteAttachmentSucceeds(it.attachmentId) + } + + // When + deleteAllAttachments(userId, senderEmail, messageId) + + // Then + MessageWithBodySample.MessageWithAttachments.messageBody.attachments.forEach { + coVerify { attachmentRepository.deleteAttachment(userId, messageId, it.attachmentId) } + } + } + + @Test + fun `when deleting of an attachment fails, the other attachment still get deleted`() = runTest { + // Given + expectGetLocalDraftSucceeds() + val attachments = MessageWithBodySample.MessageWithAttachments.messageBody.attachments + expectAttachmentRepositoryDeleteAttachmentFails(attachments.first().attachmentId) + attachments.takeLast(2).forEach { + expectAttachmentRepositoryDeleteAttachmentSucceeds(it.attachmentId) + } + + // When + deleteAllAttachments(userId, senderEmail, messageId) + + // Then + MessageWithBodySample.MessageWithAttachments.messageBody.attachments.forEach { + coVerify { attachmentRepository.deleteAttachment(userId, messageId, it.attachmentId) } + } + } + + private fun expectGetLocalDraftSucceeds() { + coEvery { + getLocalDraft(userId, messageId, senderEmail) + } returns MessageWithBodySample.MessageWithAttachments.right() + } + + private fun expectAttachmentRepositoryDeleteAttachmentSucceeds(attachmentId: AttachmentId) { + coEvery { + attachmentRepository.deleteAttachment(userId, messageId, attachmentId) + } returns Unit.right() + } + + private fun expectAttachmentRepositoryDeleteAttachmentFails(attachmentId: AttachmentId) { + coEvery { + attachmentRepository.deleteAttachment(userId, messageId, attachmentId) + } returns DataError.Local.FailedToDeleteFile.left() + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachmentTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachmentTest.kt new file mode 100644 index 0000000000..a5caf8efe3 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteAttachmentTest.kt @@ -0,0 +1,110 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals + +class DeleteAttachmentTest { + + private val userId = UserId("userId") + private val senderEmail = SenderEmail("senderEmail") + private val messageId = MessageIdSample.MessageWithAttachments + private val attachmentId = AttachmentId("attachmentId") + + private val getLocalDraft: GetLocalDraft = mockk() + private val attachmentRepository: AttachmentRepository = mockk() + + private val deleteAttachment = DeleteAttachment(getLocalDraft, attachmentRepository) + + @Test + fun `deleteAttachment should call attachment repository`() = runTest { + // Given + expectGetLocalDraftSucceeds() + expectComposerDeleteAttachmentSucceeds() + + // When + deleteAttachment(userId, senderEmail, messageId, attachmentId) + + // Then + coVerifyOrder { + attachmentRepository.deleteAttachment(userId, messageId, attachmentId) + } + } + + @Test + fun `deleteAttachment should return draft not found error when draft doesn't exist`() = runTest { + // Given + val expected = AttachmentDeleteError.DraftNotFound.left() + expectGetLocalDraftFails() + + // When + val actual = deleteAttachment(userId, senderEmail, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + coVerify { attachmentRepository wasNot Called } + } + + @Test + fun `deleteAttachment should return failed to delete file error when file deletion failed`() = runTest { + // Given + val expected = AttachmentDeleteError.FailedToDeleteFile.left() + expectGetLocalDraftSucceeds() + expectComposerAttachmentDeleteFails(DataError.Local.FailedToDeleteFile.left()) + + // When + val actual = deleteAttachment(userId, senderEmail, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + } + + @Test + fun `deleteAttachment should return unknown error when unknown error is returned from the repo failed`() = runTest { + // Given + val expected = AttachmentDeleteError.Unknown.left() + expectGetLocalDraftSucceeds() + expectComposerAttachmentDeleteFails(DataError.Local.Unknown.left()) + + // When + val actual = deleteAttachment(userId, senderEmail, messageId, attachmentId) + + // Then + assertEquals(expected, actual) + } + + private fun expectComposerDeleteAttachmentSucceeds() { + coEvery { attachmentRepository.deleteAttachment(userId, messageId, attachmentId) } returns Unit.right() + } + + private fun expectGetLocalDraftSucceeds() { + coEvery { + getLocalDraft(userId, messageId, senderEmail) + } returns MessageWithBodySample.MessageWithAttachments.right() + } + + private fun expectGetLocalDraftFails() { + coEvery { + getLocalDraft(userId, messageId, senderEmail) + } returns GetLocalDraft.Error.ResolveUserAddressError.left() + } + + private fun expectComposerAttachmentDeleteFails(error: Either) { + coEvery { attachmentRepository.deleteAttachment(userId, messageId, attachmentId) } returns error + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftStateTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftStateTest.kt new file mode 100644 index 0000000000..d70b127b83 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteDraftStateTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.usecase.DeleteDraftState +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DeleteDraftStateTest { + + private val draftStateRepository = mockk() + + private val deleteDraftState = DeleteDraftState(draftStateRepository) + + @Test + fun `delete draft state delegates to repository`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = DraftStateSample.RemoteDraftInErrorSendingState.messageId + coJustRun { draftStateRepository.deleteDraftState(userId, messageId) } + + // When + deleteDraftState(userId, messageId) + + // Then + coVerify { draftStateRepository.deleteDraftState(userId, messageId) } + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePasswordTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePasswordTest.kt new file mode 100644 index 0000000000..f8c17ae874 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DeleteMessagePasswordTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class DeleteMessagePasswordTest { + + val userId = UserIdTestData.userId + val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val apiMessageId = MessageId("apiMessageId") + + private val draftStateRepository = mockk() + private val messagePasswordRepository = mockk() + private val transactor = FakeTransactor() + + private val deleteMessagePassword = DeleteMessagePassword( + draftStateRepository = draftStateRepository, + messagePasswordRepository = messagePasswordRepository, + transactor = transactor + ) + + @Test + fun `should call delete method from repository when deleting message password and api message id exists`() = + runTest { + // Given + expectApiMessageIdExists() + coEvery { messagePasswordRepository.deleteMessagePassword(userId, apiMessageId) } just runs + + // When + deleteMessagePassword(userId, messageId) + + // Then + coVerify { messagePasswordRepository.deleteMessagePassword(userId, apiMessageId) } + } + + @Test + fun `should call delete method from repository when deleting message password and api message id does not exist`() = + runTest { + // Given + expectApiMessageIdDoesNotExist() + coEvery { messagePasswordRepository.deleteMessagePassword(userId, messageId) } just runs + + // When + deleteMessagePassword(userId, messageId) + + // Then + coVerify { messagePasswordRepository.deleteMessagePassword(userId, messageId) } + } + + private fun expectApiMessageIdExists() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = apiMessageId, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } + + private fun expectApiMessageIdDoesNotExist() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = null, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraftTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraftTest.kt new file mode 100644 index 0000000000..7472efb67b --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DiscardDraftTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.maillabel.domain.model.SystemLabelId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.usecase.DeleteMessages +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import kotlin.test.Test + +class DiscardDraftTest { + + private val findLocalDraft = mockk() + private val deleteMessages = mockk() + private val draftRepository = mockk() + private val draftStateRepository = mockk() + + private val discardDraft = DiscardDraft(findLocalDraft, deleteMessages, draftRepository, draftStateRepository) + + @Test + fun `invoke cancels upload draft, draft state and deletes message`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val draft = MessageWithBodySample.RemoteDraft + val draftMessageId = draft.message.messageId + + givenFindLocalDraftReturnsDraft(userId, messageId, draft) + givenDeleteDraftStateSucceeds(userId, draftMessageId) + givenDeleteMessagesSucceeds(userId, draftMessageId, SystemLabelId.Drafts.labelId) + givenCancelUploadDraftSucceeds() + + // When + discardDraft(userId, messageId) + + // Then + coVerifySequence { + draftRepository.cancelUploadDraft(draftMessageId) + draftStateRepository.deleteDraftState(userId, draftMessageId) + deleteMessages(userId, listOf(draftMessageId), SystemLabelId.Drafts.labelId) + } + } + + private fun givenFindLocalDraftReturnsDraft( + userId: UserId, + messageId: MessageId, + draft: MessageWithBody + ) { + coEvery { findLocalDraft(userId, messageId) } returns draft + } + + private fun givenCancelUploadDraftSucceeds() { + coEvery { draftRepository.cancelUploadDraft(any()) } returns Unit + } + + private fun givenDeleteMessagesSucceeds( + userId: UserId, + messageId: MessageId, + labelId: LabelId + ) { + coEvery { deleteMessages(userId, listOf(messageId), labelId) } returns Unit + } + + private fun givenDeleteDraftStateSucceeds(userId: UserId, messageId: MessageId) { + coEvery { draftStateRepository.deleteDraftState(userId, messageId) } returns Unit.right() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTrackerTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTrackerTest.kt new file mode 100644 index 0000000000..0c5a7c9bc1 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploadTrackerTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.ConversationId +import ch.protonmail.android.mailcommon.domain.sample.ConversationIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentCount +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.Message +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageBody +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.model.Sender +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.message.MessageAttachmentEntityTestData.createHeaderMap +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.user.domain.entity.AddressId +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DraftUploadTrackerTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.LocalDraft + private val sampleDraft = createTestMessageWithBody(userId, messageId) + + private val findLocalDraft = mockk() + private val draftStateRepository = mockk() + + private val draftUploadTracker = DraftUploadTracker(findLocalDraft, draftStateRepository) + + private val uploadRequiringChanges: List = listOf( + sampleDraft.copy(message = sampleDraft.message.copy(subject = "New Subject")), + sampleDraft.copy( + message = sampleDraft.message.copy( + sender = + Sender("new_sender@example.com", "New Sender", false) + ) + ), + sampleDraft.copy( + message = sampleDraft.message.copy( + toList = + listOf(Recipient("new_to@example.com", "New Recipient", false)) + ) + ), + sampleDraft.copy( + message = sampleDraft.message.copy( + ccList = + listOf(Recipient("new_cc@example.com", "New CC Recipient", false)) + ) + ), + sampleDraft.copy( + message = sampleDraft.message.copy( + bccList = + listOf(Recipient("new_bcc@example.com", "New BCC Recipient", false)) + ) + ), + sampleDraft.copy(message = sampleDraft.message.copy(order = 2L)), + sampleDraft.copy(message = sampleDraft.message.copy(flags = 1L)), + sampleDraft.copy(message = sampleDraft.message.copy(labelIds = listOf(LabelId("new_label")))), + sampleDraft.copy(message = sampleDraft.message.copy(size = 150L)), + sampleDraft.copy(message = sampleDraft.message.copy(conversationId = ConversationId("new_conversation"))), + sampleDraft.copy(message = sampleDraft.message.copy(expirationTime = System.currentTimeMillis() + 6_000)), + sampleDraft.copy(message = sampleDraft.message.copy(addressId = AddressId("new_address"))), + sampleDraft.copy(message = sampleDraft.message.copy(externalId = "new_external_id")), + sampleDraft.copy(message = sampleDraft.message.copy(numAttachments = 1)), + sampleDraft.copy(message = sampleDraft.message.copy(attachmentCount = AttachmentCount(1))), + + sampleDraft.copy(messageBody = sampleDraft.messageBody.copy(body = "New Body")), + sampleDraft.copy(messageBody = sampleDraft.messageBody.copy(spamScore = "1")), + sampleDraft.copy(messageBody = sampleDraft.messageBody.copy(header = "New Header")), + sampleDraft.copy(messageBody = sampleDraft.messageBody.copy(attachments = listOf(createSampleAttachment()))), + sampleDraft.copy(messageBody = sampleDraft.messageBody.copy(mimeType = MimeType.PlainText)), + sampleDraft.copy( + messageBody = sampleDraft.messageBody.copy( + replyTo = + Recipient("new_replyto@example.com", "New Reply Recipient Name", false) + ) + ) + ) + + @Test + fun `upload is required when draft state is not synchronized`() = runTest { + // given + val draftState = DraftState( + userId = userId, + messageId = messageId, + apiMessageId = MessageIdSample.RemoteDraft, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ) + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(draftState.right()) + + // when + val uploadRequired = draftUploadTracker.uploadRequired(userId, messageId) + + // then + assertTrue { uploadRequired } + } + + @Test + fun `upload is required when there is no last updated draft available`() = runTest { + // given + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf() + coEvery { findLocalDraft(userId, messageId) } returns sampleDraft + + // when + val uploadRequired = draftUploadTracker.uploadRequired(userId, messageId) + + // then + assertTrue { uploadRequired } + } + + @Test + fun `uploadRequired returns true when subject is changed`() = runTest { + // given + val lastUploadedDraft = sampleDraft + val localDraft = sampleDraft.copy(message = sampleDraft.message.copy(subject = "New Subject")) + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf() + coEvery { findLocalDraft(userId, messageId) } returns localDraft + draftUploadTracker.notifyUploadedDraft(messageId, lastUploadedDraft) + + // when + val uploadRequired = draftUploadTracker.uploadRequired(userId, messageId) + + // then + assertTrue { uploadRequired } + } + + @Test + fun `upload is not required when localDraft is equal to last uploaded draft`() = runTest { + // given + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf() + coEvery { findLocalDraft(userId, messageId) } returns sampleDraft + draftUploadTracker.notifyUploadedDraft(messageId, sampleDraft) + + // when + val uploadRequired = draftUploadTracker.uploadRequired(userId, messageId) + + // then + assertFalse { uploadRequired } + } + + @Test + fun `upload is required when any upload requiring parameter changes`() = runTest { + + // given + val lastUploadedDraft = sampleDraft + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf() + uploadRequiringChanges.forEach { updatedLocalDraft -> + + coEvery { findLocalDraft(userId, messageId) } returns updatedLocalDraft + draftUploadTracker.notifyUploadedDraft(messageId, lastUploadedDraft) + + // when + val uploadRequired = draftUploadTracker.uploadRequired(userId, messageId) + + // then + assertTrue { uploadRequired } + } + } + + private fun createTestMessageWithBody(userId: UserId, messageId: MessageId): MessageWithBody { + return MessageWithBody( + message = createTestMessage(userId, messageId), + messageBody = createTestMessageBody(userId, messageId) + ) + } + + private fun createTestMessage(userId: UserId, messageId: MessageId): Message { + return Message( + userId = userId, + messageId = messageId, + conversationId = ConversationIdSample.Invoices, + time = System.currentTimeMillis(), + size = 100, + order = 1, + labelIds = emptyList(), + subject = "Test Subject", + unread = true, + sender = Sender("sender@example.com", "Sender Name", false), + toList = listOf(Recipient("to@example.com", "Recipient Name", false)), + ccList = emptyList(), + bccList = emptyList(), + expirationTime = 0, + isReplied = false, + isRepliedAll = false, + isForwarded = false, + addressId = AddressId("testAddressId"), + externalId = null, + numAttachments = 0, + flags = 0, + attachmentCount = AttachmentCount(0) + ) + } + + private fun createTestMessageBody(userId: UserId, messageId: MessageId): MessageBody { + return MessageBody( + messageId = messageId, + userId = userId, + body = "Test Body", + spamScore = "0", + header = "Test Header", + attachments = emptyList(), + mimeType = MimeType.Html, + replyTo = Recipient("replyto@example.com", "Reply Recipient Name", false), + replyTos = emptyList(), + unsubscribeMethods = null + ) + } + + + private fun createSampleAttachment(): MessageAttachment { + return MessageAttachment( + attachmentId = AttachmentId("sample_attachment_id"), + name = "Sample Attachment", + size = 1024L, // 1 KB + mimeType = "application/pdf", + disposition = "inline", + keyPackets = "sample_key_packets", + signature = "sample_signature", + encSignature = "sample_encryption_signature", + headers = createHeaderMap( + "Content-Type" to "application/pdf", + "Content-Disposition" to "inline; filename=sample_attachment.pdf", + "X-Custom-Header" to "Custom Value" + ) + ) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploaderTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploaderTest.kt new file mode 100644 index 0000000000..5be469b971 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/DraftUploaderTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailcomposer.domain.repository.DraftRepository +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +class DraftUploaderTest { + + private val draftStateRepository = mockk() + private val draftRepository = mockk() + private val testDispatcher = StandardTestDispatcher() + private val coroutineScope = CoroutineScope(testDispatcher) + + private val draftUploader = DraftUploader(draftStateRepository, draftRepository, testDispatcher) + + @Test + fun `saves draft state as local when starting`() = runTest(testDispatcher) { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + val action = DraftAction.Compose + expectSaveLocalStateSuccess(userId, messageId, action) + expectSyncDraft(userId, messageId) + + // When + draftUploader.startContinuousUpload(userId, messageId, action, coroutineScope) + testDispatcher.scheduler.advanceTimeBy(1000) + draftUploader.stopContinuousUpload() + + // Then + coVerify { draftStateRepository.createOrUpdateLocalState(userId, messageId, action) } + } + + @Test + fun `keeps syncing the draft based on defined sync interval`() = runTest(testDispatcher) { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + val action = DraftAction.Compose + expectSaveLocalStateSuccess(userId, messageId, action) + expectSyncDraft(userId, messageId) + + // When + draftUploader.startContinuousUpload(userId, messageId, action, coroutineScope) + // Advance time by just a bit more than 2xSyncInterval so that 2 loops are executed + val advanceTimeMillis = DraftUploader.SyncInterval.times(2).plus(100.milliseconds).inWholeMilliseconds + testDispatcher.scheduler.advanceTimeBy(advanceTimeMillis) + draftUploader.stopContinuousUpload() + + // Then + coVerify(exactly = 2) { draftRepository.upload(userId, messageId) } + } + + @Test + fun `upload calls draft repository force upload`() = runTest(testDispatcher) { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + expectForceUploadDraft(userId, messageId) + + // When + draftUploader.upload(userId, messageId) + + // Then + coVerify(exactly = 1) { draftRepository.forceUpload(userId, messageId) } + } + + private fun expectForceUploadDraft(userId: UserId, messageId: MessageId) { + coEvery { draftRepository.forceUpload(userId, messageId) } returns Unit + } + + private fun expectSyncDraft(userId: UserId, messageId: MessageId) { + coEvery { draftRepository.upload(userId, messageId) } returns Unit + } + + private fun expectSaveLocalStateSuccess( + userId: UserId, + messageId: MessageId, + action: DraftAction.Compose + ) { + coEvery { draftStateRepository.createOrUpdateLocalState(userId, messageId, action) } returns Unit.right() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBodyTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBodyTest.kt new file mode 100644 index 0000000000..9953657857 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/EncryptDraftBodyTest.kt @@ -0,0 +1,97 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.AddressIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.key.domain.entity.key.KeyId +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.user.domain.entity.UserAddressKey +import org.junit.Test +import kotlin.test.assertEquals + +class EncryptDraftBodyTest { + + private val armoredPrivateKey = "armoredPrivateKey" + private val armoredPublicKey = "armoredPublicKey" + private val encryptedPassphrase = EncryptedByteArray("encryptedPassphrase".encodeToByteArray()) + private val decryptedPassphrase = PlainByteArray("decryptedPassPhrase".encodeToByteArray()) + private val unlockedPrivateKey = "unlockedPrivateKey".encodeToByteArray() + private val pgpCryptoMock = mockk { + every { getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + } + private val cryptoContextMock = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { keyStoreCrypto } returns mockk { + every { decrypt(encryptedPassphrase) } returns decryptedPassphrase + } + } + private val userAddressKey = UserAddressKey( + addressId = AddressIdSample.Primary, + version = 0, + flags = 0, + active = true, + keyId = KeyId("KeyId"), + privateKey = PrivateKey( + key = armoredPrivateKey, + isPrimary = true, + isActive = true, + canEncrypt = true, + canVerify = true, + passphrase = encryptedPassphrase + ) + ) + private val encryptDraftBody = EncryptDraftBody(cryptoContextMock, UnconfinedTestDispatcher()) + + @Test + fun `should return error when body encryption fails`() = runTest { + // Given + val draftBody = DraftBody("Message body") + val senderAddress = UserAddressSample.build().copy(keys = listOf(userAddressKey)) + givenBodyEncryptionFailsFor(draftBody) + + // When + val encryptionResultEither = encryptDraftBody(draftBody, senderAddress) + + // Then + assertEquals(Unit.left(), encryptionResultEither) + } + + @Test + fun `should return encrypted draft body when encryption succeeds`() = runTest { + // Given + val draftBody = DraftBody("Message body") + val expectedEncryptedDraftBody = DraftBody("Encrypted message body") + val senderAddress = UserAddressSample.build().copy(keys = listOf(userAddressKey)) + givenBodyEncryptionSucceedsFor(draftBody, encryptedDraftBody = expectedEncryptedDraftBody) + + // When + val encryptionResultEither = encryptDraftBody(draftBody, senderAddress) + + // Then + assertEquals(expectedEncryptedDraftBody.right(), encryptionResultEither) + } + + private fun givenBodyEncryptionFailsFor(draftBody: DraftBody) { + every { pgpCryptoMock.encryptAndSignText(draftBody.value, any(), any()) } throws CryptoException() + } + + private fun givenBodyEncryptionSucceedsFor(draftBody: DraftBody, encryptedDraftBody: DraftBody) { + every { + pgpCryptoMock.encryptAndSignText(draftBody.value, any(), any()) + } returns encryptedDraftBody.value + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraftTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraftTest.kt new file mode 100644 index 0000000000..6c3e06ff50 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/FindLocalDraftTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FindLocalDraftTest { + + private val messageRepository = mockk() + private val draftStateRepository = mockk() + + private val findLocalDraft = FindLocalDraft(messageRepository, draftStateRepository) + + @Test + fun `when message is found by messageId return it`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val expectedMessage = MessageWithBodySample.Invoice + val expectedDraftState = DraftStateSample.RemoteDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageSucceeds(userId, messageId, expectedMessage) + expectGetDraftStateFails(userId, messageId, DataError.Local.NoDataCached) + + // When + val actual = findLocalDraft(userId, messageId) + + // Then + assertEquals(expectedMessage, actual) + } + + @Test + fun `when message is not found by messageId but it is found by draft state apiMessageId return it`() = runTest { + /* + * This case would happen in case of concurrent executions of message creation (eg. typing a new draft while + * fully offline results in two works for upload the draft being scheduled as we append when exiting composer). + * First one succeeds, second fails as the first did update the ID of the message id DB. + */ + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val apiMessageId = MessageIdSample.RemoteDraft + val expectedMessage = MessageWithBodySample.RemoteDraft + val expectedDraftState = DraftStateSample.RemoteDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageFails(userId, messageId) + expectGetLocalMessageSucceeds(userId, apiMessageId, expectedMessage) + + // When + val actual = findLocalDraft(userId, messageId) + + // Then + assertEquals(expectedMessage, actual) + } + + @Test + fun `when message is not found by any id return null`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.LocalDraft + val apiMessageId = MessageIdSample.RemoteDraft + val expectedDraftState = DraftStateSample.RemoteDraftState + expectGetDraftStateSucceeds(userId, messageId, expectedDraftState) + expectGetLocalMessageFails(userId, messageId) + expectGetLocalMessageFails(userId, apiMessageId) + + // When + val actual = findLocalDraft(userId, messageId) + + // Then + assertNull(actual) + } + + private fun expectGetLocalMessageSucceeds( + userId: UserId, + messageId: MessageId, + expectedMessage: MessageWithBody + ) { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns expectedMessage + } + + private fun expectGetLocalMessageFails(userId: UserId, messageId: MessageId) { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns null + } + + private fun expectGetDraftStateSucceeds( + userId: UserId, + messageId: MessageId, + expectedState: DraftState + ) { + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(expectedState.right()) + } + + private fun expectGetDraftStateFails( + userId: UserId, + messageId: MessageId, + error: DataError.Local + ) { + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(error.left()) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKeyTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKeyTest.kt new file mode 100644 index 0000000000..b20655b101 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetAddressPublicKeyTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.AddressPublicKey +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.user.domain.entity.UserAddressKey +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class GetAddressPublicKeyTest { + + private val resolveUserAddress = mockk() + + private val mockedSessionKey = SessionKey("mockedSessionKey".encodeToByteArray()) + private val mockedKeyPacket = "mockedKeyPacket".encodeToByteArray() + + private val armoredPrivateKey = "armoredPrivateKey" + private val armoredPublicKey = "armoredPublicKey" + private val encryptedPassphrase = EncryptedByteArray("encryptedPassphrase".encodeToByteArray()) + private val decryptedPassphrase = PlainByteArray("decryptedPassPhrase".encodeToByteArray()) + private val unlockedPrivateKey = "unlockedPrivateKey".encodeToByteArray() + + private val userAddressKey = mockk { + every { privateKey } returns PrivateKey( + key = armoredPrivateKey, + isPrimary = true, + isActive = true, + canEncrypt = true, + canVerify = true, + passphrase = encryptedPassphrase + ) + } + + private val pgpCryptoMock = mockk { + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + every { getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { generateNewSessionKey() } returns mockedSessionKey + every { encryptSessionKey(mockedSessionKey, armoredPublicKey) } returns mockedKeyPacket + every { decryptSessionKey(mockedKeyPacket, unlockedPrivateKey) } returns mockedSessionKey + } + + private val cryptoContext = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { keyStoreCrypto } returns mockk { + every { decrypt(encryptedPassphrase) } returns decryptedPassphrase + } + } + + private val testDispatcher: TestDispatcher by lazy { + StandardTestDispatcher() + } + + private val getAddressPublicKey = GetAddressPublicKey( + resolveUserAddress, + cryptoContext, + testDispatcher + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `should return AddressNotFound when the user address fails to be resolved`() = runTest { + // Given + coEvery { + resolveUserAddress(UserId, SenderEmail.value) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + + // When + val result = getAddressPublicKey(UserId, SenderEmail) + + // Then + assertEquals(GetAddressPublicKey.Error.AddressNotFound.left(), result) + } + + @Test + fun `should return PublicKeyNotFound when the key can't be obtained from the crypto context`() = runTest { + // Given + coEvery { + resolveUserAddress(UserId, SenderEmail.value) + } returns UserAddressSample.AliasAddress.copy(keys = listOf()).right() + + // When + val result = getAddressPublicKey(UserId, SenderEmail) + + // Then + assertEquals(GetAddressPublicKey.Error.PublicKeyNotFound.left(), result) + } + + @Test + fun `should return PublicKeyNotFound when the PK can't be obtained`() = runTest { + // Given + coEvery { + resolveUserAddress(UserId, SenderEmail.value) + } returns UserAddressSample.AliasAddress.copy(keys = listOf(userAddressKey)).right() + + every { cryptoContext.pgpCrypto.getPublicKey(armoredPrivateKey) } throws Exception() + every { cryptoContext.pgpCrypto.getFingerprint(armoredPublicKey) } + + // When + val result = getAddressPublicKey(UserId, SenderEmail) + + // Then + assertEquals(GetAddressPublicKey.Error.PublicKeyNotFound.left(), result) + } + + @Test + fun `should return PublicKeyNotFound when the PK fingerprint can't be obtained`() = runTest { + // Given + coEvery { + resolveUserAddress(UserId, SenderEmail.value) + } returns UserAddressSample.AliasAddress.copy(keys = listOf(userAddressKey)).right() + + every { cryptoContext.pgpCrypto.getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { cryptoContext.pgpCrypto.getFingerprint(armoredPublicKey) } throws Exception() + + // When + val result = getAddressPublicKey(UserId, SenderEmail) + + // Then + assertEquals(GetAddressPublicKey.Error.PublicKeyFingerprint.left(), result) + } + + @Test + fun `should return a valid AddressPublicKey when invoked`() = runTest { + // Given + coEvery { + resolveUserAddress(UserId, SenderEmail.value) + } returns UserAddressSample.AliasAddress.copy(keys = listOf(userAddressKey)).right() + + every { cryptoContext.pgpCrypto.getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { cryptoContext.pgpCrypto.getFingerprint(armoredPublicKey) } returns "01A2b3C4D5E" + + val expectedAddressPublicKey = AddressPublicKey( + fileName = "publickey - alias@protonmail.ch - 0x01A2B3C4.asc", + mimeType = "application/pgp-keys", + bytes = armoredPublicKey.toByteArray() + ) + + // When + val result = getAddressPublicKey(UserId, SenderEmail).getOrNull() + + // Then + assertEquals(expectedAddressPublicKey, result) + } + + private companion object { + + val UserId = UserIdSample.Primary + val SenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddressesTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddressesTest.kt new file mode 100644 index 0000000000..ba6184159c --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetComposerSenderAddressesTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.usecase.IsPaidMailUser +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +class GetComposerSenderAddressesTest { + + private val userId = UserIdTestData.userId + private val addresses = listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) + + private val observePrimaryUserId = mockk { + every { this@mockk.invoke() } returns flowOf(userId) + } + private val isPaidMailUser = mockk() + private val observeUserAddresses = mockk { + coEvery { this@mockk(userId) } returns flowOf(addresses) + } + + private val getComposerSenderAddresses = GetComposerSenderAddresses( + observePrimaryUserId, + isPaidMailUser, + observeUserAddresses + ) + + @Test + fun `returns paid feature error when user account has 1 send enabled address only and no paid mail subscription`() = + runTest { + // Given + coEvery { isPaidMailUser(userId) } returns false.right() + + val addresses = listOf( + UserAddressSample.PrimaryAddress, + UserAddressSample.AliasAddress.copy(canSend = false), + UserAddressSample.ExternalAddressWithoutSend + ) + + every { observeUserAddresses(userId) } returns flowOf(addresses) + + + // When + val actual = getComposerSenderAddresses() + + // Then + Assert.assertEquals(GetComposerSenderAddresses.Error.UpgradeToChangeSender.left(), actual) + } + + @Test + fun `returns list of addresses to free user when addresses are send-enabled`() = runTest { + // Given + coEvery { isPaidMailUser(userId) } returns false.right() + + // When + val actual = getComposerSenderAddresses() + + // Then + Assert.assertEquals(addresses.right(), actual) + } + + @Test + fun `returns list of user addresses when user has a paid mail subscription`() = runTest { + // Given + coEvery { isPaidMailUser(userId) } returns true.right() + + // When + val actual = getComposerSenderAddresses() + + // Then + Assert.assertEquals(addresses.right(), actual) + } + + @Test + fun `excludes disabled and includes external accounts with send permission`() = runTest { + // Given + val allUserAddresses = listOf( + UserAddressSample.PrimaryAddress, + UserAddressSample.DisabledAddress, + UserAddressSample.ExternalAddressWithSend, + UserAddressSample.ExternalAddressWithoutSend + ) + coEvery { isPaidMailUser(userId) } returns true.right() + coEvery { observeUserAddresses(userId) } returns flowOf(allUserAddresses) + + // When + val actual = getComposerSenderAddresses() + + // Then + val expected = listOf( + UserAddressSample.PrimaryAddress, + UserAddressSample.ExternalAddressWithSend + ) + Assert.assertEquals(expected.right(), actual) + } + + @Test + fun `allows free users to switch to their external address`() = runTest { + // Given + val allUserAddresses = listOf( + UserAddressSample.PrimaryAddress, + UserAddressSample.ExternalAddressWithSend + ) + coEvery { isPaidMailUser(userId) } returns false.right() + coEvery { observeUserAddresses(userId) } returns flowOf(allUserAddresses) + + // When + val actual = getComposerSenderAddresses() + + // Then + val expected = listOf( + UserAddressSample.PrimaryAddress, + UserAddressSample.ExternalAddressWithSend + ) + Assert.assertEquals(expected.right(), actual) + } + + @Test + fun `returns failed getting primary user error when no primary user exists`() = runTest { + // Given + every { observePrimaryUserId() } returns flowOf(null) + + // When + val actual = getComposerSenderAddresses() + + // Then + Assert.assertEquals(GetComposerSenderAddresses.Error.FailedGettingPrimaryUser.left(), actual) + } + + @Test + fun `returns failed determining user subscription when is paid user fails`() = runTest { + // Given + coEvery { isPaidMailUser(userId) } returns DataError.Local.NoDataCached.left() + + // When + val actual = getComposerSenderAddresses() + + // Then + Assert.assertEquals(GetComposerSenderAddresses.Error.FailedDeterminingUserSubscription.left(), actual) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFieldsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFieldsTest.kt new file mode 100644 index 0000000000..53576af048 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetDecryptedDraftFieldsTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.GetDecryptedMessageBodyError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.RefreshedMessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals + +class GetDecryptedDraftFieldsTest { + + private val messageRepository = mockk() + private val getDecryptedMessageBody = mockk() + private val splitMessageBodyHtmlQuote = mockk() + + private val getDecryptedDraftFields = GetDecryptedDraftFields( + messageRepository, + getDecryptedMessageBody, + splitMessageBodyHtmlQuote + ) + + @Test + fun `returns remote draft data when get refreshed message and decrypt operations succeed`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + val decryptedMessageBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody() + val expectedMessage = MessageWithBodySample.RemoteDraft + expectedGetRefreshedMessage(userId, messageId) { RefreshedMessageWithBody(expectedMessage, isRefreshed = true) } + expectDecryptedMessageResult(userId, messageId) { decryptedMessageBody } + expectSplitMessageBodyHtmlQuote(decryptedMessageBody) { Pair(DraftBody(decryptedMessageBody.value), null) } + + // When + val actual = getDecryptedDraftFields(userId, messageId) + + // Then + val expected = DecryptedDraftFields.Remote(expectedMessage.toDraftFields(decryptedMessageBody.value)) + assertEquals(expected.right(), actual) + } + + @Test + fun `returns local draft data when get refreshed message returns local data and decrypt operations succeed`() = + runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + val decryptedMessageBody = DecryptedMessageBodyTestData.buildDecryptedMessageBody() + val expectedMessage = MessageWithBodySample.RemoteDraft + expectedGetRefreshedMessage(userId, messageId) { + RefreshedMessageWithBody(expectedMessage, isRefreshed = false) + } + expectDecryptedMessageResult(userId, messageId) { decryptedMessageBody } + expectSplitMessageBodyHtmlQuote(decryptedMessageBody) { Pair(DraftBody(decryptedMessageBody.value), null) } + + // When + val actual = getDecryptedDraftFields(userId, messageId) + + // Then + val expected = DecryptedDraftFields.Local(expectedMessage.toDraftFields(decryptedMessageBody.value)) + assertEquals(expected.right(), actual) + } + + @Test + fun `returns no cached data error when get refreshed message with body fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + expectedGetRefreshedMessageError(userId, messageId) + expectGetLocalMessageFailure(userId, messageId) + + // When + val actual = getDecryptedDraftFields(userId, messageId) + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + + @Test + fun `returns decryption error when decrypt message with body fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + val expectedMessage = MessageWithBodySample.RemoteDraft + val decryptError = GetDecryptedMessageBodyError.Decryption(messageId, "Failed decrypting") + expectedGetRefreshedMessage(userId, messageId) { RefreshedMessageWithBody(expectedMessage, isRefreshed = true) } + expectDecryptedMessageError(userId, messageId) { decryptError } + + // When + val actual = getDecryptedDraftFields(userId, messageId) + + // Then + assertEquals(DataError.Local.DecryptionError.left(), actual) + } + + + private fun expectSplitMessageBodyHtmlQuote( + decryptedBody: DecryptedMessageBody, + result: () -> Pair + ) = result().also { + coEvery { splitMessageBodyHtmlQuote(decryptedBody) } returns it + } + + private fun expectDecryptedMessageError( + userId: UserId, + messageId: MessageId, + error: () -> GetDecryptedMessageBodyError + ) = error().also { + coEvery { getDecryptedMessageBody(userId, messageId) } returns it.left() + } + + private fun expectGetLocalMessageFailure(userId: UserId, messageId: MessageId) { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns null + } + + private fun expectedGetRefreshedMessageError(userId: UserId, messageId: MessageId) { + coEvery { messageRepository.getRefreshedMessageWithBody(userId, messageId) } returns null + } + + private fun expectDecryptedMessageResult( + userId: UserId, + messageId: MessageId, + result: () -> DecryptedMessageBody + ) = result().also { + coEvery { getDecryptedMessageBody(userId, messageId) } returns it.right() + } + + private fun expectedGetRefreshedMessage( + userId: UserId, + messageId: MessageId, + result: () -> RefreshedMessageWithBody + ) = result().also { + coEvery { messageRepository.getRefreshedMessageWithBody(userId, messageId) } returns it + } + + private fun MessageWithBody.toDraftFields(decryptedBody: String) = with(message) { + DraftFields( + SenderEmail(this.sender.address), + Subject(this.subject), + DraftBody(decryptedBody), + RecipientsTo(this.toList), + RecipientsCc(this.ccList), + RecipientsBcc(this.bccList), + null + ) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipientsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipientsTest.kt new file mode 100644 index 0000000000..106d5032b2 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetExternalRecipientsTest.kt @@ -0,0 +1,80 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.key.domain.entity.key.PublicAddress +import me.proton.core.key.domain.repository.PublicAddressRepository +import me.proton.core.key.domain.repository.getPublicAddressOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetExternalRecipientsTest { + + private val userId = UserIdTestData.userId + private val internalEmailAddress = "internal@pm.me" + private val externalEmailAddress = "external@external.com" + private val internalParticipant = Participant(internalEmailAddress, "Internal") + private val externalParticipant = Participant(externalEmailAddress, "External") + private val internalPublicAddress = PublicAddress( + email = internalEmailAddress, + recipientType = 1, + mimeType = null, + keys = emptyList(), + signedKeyList = null, + ignoreKT = null + ) + private val externalPublicAddress = PublicAddress( + email = externalEmailAddress, + recipientType = 2, + mimeType = null, + keys = emptyList(), + signedKeyList = null, + ignoreKT = null + ) + + private val publicAddressRepository = mockk { + coEvery { getPublicAddressOrNull(userId, internalEmailAddress) } returns internalPublicAddress + coEvery { getPublicAddressOrNull(userId, externalEmailAddress) } returns externalPublicAddress + } + + private val getExternalRecipients = GetExternalRecipients(publicAddressRepository) + + @Test + fun `should return a list of external participants`() = runTest { + // When + val actual = getExternalRecipients( + userId, + RecipientsTo(listOf(internalParticipant)), + RecipientsCc(listOf(externalParticipant)), + RecipientsBcc(emptyList()) + ) + + // Then + val expected = listOf(externalParticipant) + assertEquals(expected, actual) + } + + @Test + fun `should return an empty list when getting the public address fails`() = runTest { + // Given + coEvery { publicAddressRepository.getPublicAddressOrNull(userId, externalEmailAddress) } returns null + + // When + val actual = getExternalRecipients( + userId, + RecipientsTo(listOf(internalParticipant)), + RecipientsCc(listOf(externalParticipant)), + RecipientsBcc(emptyList()) + ) + + // Then + val expected = emptyList() + assertEquals(expected, actual) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraftTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraftTest.kt new file mode 100644 index 0000000000..cdd8382f2e --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalDraftTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Test +import kotlin.test.assertEquals + +class GetLocalDraftTest { + + private val createEmptyDraftMock = mockk() + private val findLocalDraftMock = mockk() + private val resolveUserAddressMock = mockk() + + private val getLocalDraft = GetLocalDraft( + createEmptyDraftMock, + findLocalDraftMock, + resolveUserAddressMock + ) + + @Test + fun `returns error when sender email fails being resolved`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail("unresolvable@sender.email") + val draftMessageId = MessageIdSample.build() + expectResolveUserAddressFailure(userId, senderEmail) + + // When + val actualEither = getLocalDraft(userId, draftMessageId, senderEmail) + + // Then + assertEquals(GetLocalDraft.Error.ResolveUserAddressError.left(), actualEither) + } + + @Test + fun `returns existing draft when it exists locally`() = runTest { + // Given + val userId = UserIdSample.Primary + val userAddress = UserAddressSample.PrimaryAddress + val senderEmail = SenderEmail(userAddress.email) + val draftMessageId = MessageIdSample.build() + val expectedExistingDraft = expectedExistingDraft(userId, draftMessageId) { MessageWithBodySample.EmptyDraft } + expectedResolvedUserAddress(userId, senderEmail) { userAddress } + + // When + val actualEither = getLocalDraft(userId, draftMessageId, senderEmail) + + // Then + assertEquals(expectedExistingDraft.right(), actualEither) + } + + @Test + fun `create and returns new empty draft when it does not exist locally`() = runTest { + // Given + val userId = UserIdSample.Primary + val userAddress = UserAddressSample.PrimaryAddress + val senderEmail = SenderEmail(userAddress.email) + val draftMessageId = MessageIdSample.build() + val expectedNewDraft = expectedNewDraft(userId, draftMessageId, userAddress) { + MessageWithBodySample.EmptyDraft + } + expectedResolvedUserAddress(userId, senderEmail) { userAddress } + + // When + val actualEither = getLocalDraft(userId, draftMessageId, senderEmail) + + // Then + assertEquals(expectedNewDraft.right(), actualEither) + } + + private fun expectedNewDraft( + userId: UserId, + messageId: MessageId, + senderAddress: UserAddress, + existingDraft: () -> MessageWithBody + ): MessageWithBody = existingDraft().also { + coEvery { findLocalDraftMock(userId, messageId) } returns null + every { createEmptyDraftMock(messageId, userId, senderAddress) } returns it + } + + private fun expectedExistingDraft( + userId: UserId, + messageId: MessageId, + existingDraft: () -> MessageWithBody + ): MessageWithBody = existingDraft().also { + coEvery { findLocalDraftMock(userId, messageId) } returns it + } + + private fun expectedResolvedUserAddress( + userId: UserId, + email: SenderEmail, + address: () -> UserAddress + ) = address().also { coEvery { resolveUserAddressMock(userId, email.value) } returns it.right() } + + private fun expectResolveUserAddressFailure(userId: UserId, email: SenderEmail) { + coEvery { + resolveUserAddressMock( + userId, + email.value + ) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecryptedTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecryptedTest.kt new file mode 100644 index 0000000000..acc18db849 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/GetLocalMessageDecryptedTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.GetDecryptedMessageBodyError +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.usecase.GetDecryptedMessageBody +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetLocalMessageDecryptedTest { + + private val messageRepository = mockk() + private val getDecryptedMessageBody = mockk() + + private val getDraftFieldsFromParent = GetLocalMessageDecrypted( + messageRepository, + getDecryptedMessageBody + ) + + @Test + fun `returns message with decrypted body when get local message and decrypt operations succeed`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + expectedGetLocalMessage(userId, messageId) { MessageWithBodySample.Invoice } + expectDecryptedMessageResult(userId, messageId) { + DecryptedMessageBodyTestData.buildDecryptedMessageBody() + } + + // When + val actual = getDraftFieldsFromParent(userId, messageId) + + // Then + assertTrue(actual.isRight()) + } + + @Test + fun `returns no cached data error when get local message with body fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + expectGetLocalMessageFailure(userId, messageId) + + // When + val actual = getDraftFieldsFromParent(userId, messageId) + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + + @Test + fun `returns decryption error when decrypt message with body fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.RemoteDraft + expectedGetLocalMessage(userId, messageId) { MessageWithBodySample.RemoteDraft } + expectDecryptedMessageError(userId, messageId) { + GetDecryptedMessageBodyError.Decryption(messageId, "Failed decrypting") + } + + // When + val actual = getDraftFieldsFromParent(userId, messageId) + + // Then + assertEquals(DataError.Local.DecryptionError.left(), actual) + } + + + private fun expectDecryptedMessageError( + userId: UserId, + messageId: MessageId, + error: () -> GetDecryptedMessageBodyError + ) = error().also { + coEvery { getDecryptedMessageBody(userId, messageId) } returns it.left() + } + + private fun expectGetLocalMessageFailure(userId: UserId, messageId: MessageId) { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns null + } + + private fun expectDecryptedMessageResult( + userId: UserId, + messageId: MessageId, + result: () -> DecryptedMessageBody + ) = result().also { + coEvery { getDecryptedMessageBody(userId, messageId) } returns it.right() + } + + private fun expectedGetLocalMessage( + userId: UserId, + messageId: MessageId, + result: () -> MessageWithBody + ) = result().also { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns it + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApiTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApiTest.kt new file mode 100644 index 0000000000..ceadb6791a --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsDraftKnownToApiTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.util.UUID +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IsDraftKnownToApiTest { + + private val isDraftKnowToApi = IsDraftKnownToApi() + + @Test + fun `returns true when draft state has apiMessageId and messageId is not in UUID format`() { + // Given + val expectedDraftState = DraftStateSample.RemoteDraftState + + // When + val actual = isDraftKnowToApi(expectedDraftState) + + // Then + assertTrue(actual) + } + + @Test + fun `returns true when draft state has apiMessageId and messageId is in UUID format`() { + // Given + val expectedDraftState = DraftStateSample.LocalDraftThatWasSyncedOnce + println("UUID.randomUUID() = ${UUID.randomUUID()}") + + // When + val actual = isDraftKnowToApi(expectedDraftState) + + // Then + assertTrue(actual) + } + + + @Test + fun `returns true when draft state does not have apiMessageId and messageId is not in UUID format`() { + // Given + val expectedDraftState = DraftStateSample.RemoteWithoutApiMessageId + + // When + val actual = isDraftKnowToApi(expectedDraftState) + + // Then + assertTrue(actual) + } + + @Test + fun `returns false when draft state does not have apiMessageId and messageId is in UUID format`() { + // Given + val expectedDraftState = DraftStateSample.LocalDraftNeverSynced + + // When + val actual = isDraftKnowToApi(expectedDraftState) + + // Then + assertFalse(actual) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddressTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddressTest.kt new file mode 100644 index 0000000000..c942d34096 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/IsValidEmailAddressTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class IsValidEmailAddressTest { + + @Test + fun `Empty email address should not be valid`() { + // Given + val emailAddress = "" + + // When + val isValid = IsValidEmailAddress().invoke(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without @ should not be valid`() { + // Given + val emailAddress = "test" + + // When + val isValid = IsValidEmailAddress().invoke(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without domain should not be valid`() { + // Given + val emailAddress = "test@" + + // When + val isValid = IsValidEmailAddress().invoke(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without local part should not be valid`() { + // Given + val emailAddress = "@test.com" + + // When + val isValid = IsValidEmailAddress().invoke(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `A valid email address should be valid`() { + // Given + val emailAddress = "a@b.c" + + // When + val isValid = IsValidEmailAddress().invoke(emailAddress) + + // Then + assertTrue(isValid) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimisticallyTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimisticallyTest.kt new file mode 100644 index 0000000000..d40b5b9bea --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/MoveToSentOptimisticallyTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MoveToSentOptimisticallyTest { + + @get:Rule + val loggerRule = LoggingTestRule() + + private val messageRepository: MessageRepository = mockk() + private val findLocalDraft = mockk() + + private val moveToSentOptimistically = MoveToSentOptimistically( + messageRepository, + findLocalDraft + ) + + @Test + fun `moves message to draft folder using local draft's message id`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.PlainTextMessage + val expectedLocalDraft = MessageWithBodySample.RemoteDraft + coEvery { findLocalDraft(userId, messageId) } returns expectedLocalDraft + coEvery { + messageRepository.moveMessageFromDraftsToSent(userId, expectedLocalDraft.message.messageId) + } returns Unit.right() + + // When + moveToSentOptimistically(userId, messageId) + + // Then + coVerify { messageRepository.moveMessageFromDraftsToSent(userId, expectedLocalDraft.message.messageId) } + } + + @Test + fun `moves message to draft folder using given message id when local draft is not found exist`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.PlainTextMessage + coEvery { findLocalDraft(userId, messageId) } returns null + coEvery { messageRepository.moveMessageFromDraftsToSent(userId, messageId) } returns Unit.right() + + // When + moveToSentOptimistically(userId, messageId) + + // Then + coVerify { messageRepository.moveMessageFromDraftsToSent(userId, messageId) } + loggerRule.assertErrorLogged("Local draft not found while trying to move sending message to sent $messageId") + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachmentsTest.kt new file mode 100644 index 0000000000..6cf197c20a --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageAttachmentsTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ObserveMessageAttachmentsTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.LocalDraft + + private val draftStateRepository = mockk() + private val messageRepository = mockk() + + + private val observeMessageAttachments = ObserveMessageAttachments(draftStateRepository, messageRepository) + + @Test + fun `should observe draft state and message repo to observe message attachments`() = runTest { + // Given + every { draftStateRepository.observe(userId, messageId) } returns flowOf(DraftStateSample.NewDraftState.right()) + val expectedAttachment = listOf(MessageAttachmentSample.invoice) + every { messageRepository.observeMessageAttachments(userId, messageId) } returns flowOf( + expectedAttachment + ) + + // When + val actual = observeMessageAttachments(userId, messageId) + + // Then + assertEquals(expectedAttachment, actual.first()) + verify { messageRepository.observeMessageAttachments(userId, messageId) } + } + + @Test + fun `should update message attachment observation but not emitting when draft state changes`() = runTest { + // Given + val draftStateFlow = MutableStateFlow(DraftStateSample.NewDraftState.right()) + every { draftStateRepository.observe(userId, messageId) } returns draftStateFlow + val expectedAttachment = listOf(MessageAttachmentSample.invoice) + + every { messageRepository.observeMessageAttachments(userId, messageId) } returns flowOf( + expectedAttachment + ) + every { messageRepository.observeMessageAttachments(userId, MessageIdSample.RemoteDraft) } returns flowOf( + expectedAttachment + ) + + // When + observeMessageAttachments(userId, messageId).test { + // Then + assertEquals(expectedAttachment, awaitItem()) + draftStateFlow.value = DraftStateSample.RemoteDraftState.right() + expectNoEvents() + + coVerifyOrder { + draftStateRepository.observe(userId, messageId) + messageRepository.observeMessageAttachments(userId, messageId) + messageRepository.observeMessageAttachments(userId, MessageIdSample.RemoteDraft) + } + } + } + + @Test + fun `should load attachments when messageId changes and attachment observer returns empty list`() = runTest { + // Given + val expectedAttachment = listOf(MessageAttachmentSample.invoice) + val initialDraftStateFlow = MutableStateFlow(DraftStateSample.NewDraftState.right()) + val updatedDraftStateFlow = MutableStateFlow(DraftStateSample.RemoteDraftState.right()) + val attachmentFlow = MutableStateFlow(expectedAttachment) + + every { + draftStateRepository.observe(userId, messageId) + } returns initialDraftStateFlow andThen updatedDraftStateFlow + + every { messageRepository.observeMessageAttachments(userId, messageId) } returns attachmentFlow + every { messageRepository.observeMessageAttachments(userId, MessageIdSample.RemoteDraft) } returns flowOf( + expectedAttachment + ) + + // When + observeMessageAttachments(userId, messageId).test { + // Then + assertEquals(expectedAttachment, awaitItem()) + attachmentFlow.value = emptyList() + expectNoEvents() + + coVerifyOrder { + draftStateRepository.observe(userId, messageId) + messageRepository.observeMessageAttachments(userId, messageId) + draftStateRepository.observe(userId, messageId) + messageRepository.observeMessageAttachments(userId, MessageIdSample.RemoteDraft) + } + } + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTimeTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTimeTest.kt new file mode 100644 index 0000000000..3aa162fcbe --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessageExpirationTimeTest.kt @@ -0,0 +1,118 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days + +class ObserveMessageExpirationTimeTest { + + private val userId = UserIdTestData.userId + private val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val apiMessageId = MessageId("apiMessageId") + private val messageExpirationTime = MessageExpirationTime(userId, messageId, 1.days) + + private val draftStateRepository = mockk() + private val messageExpirationTimeRepository = mockk() + private val transactor = FakeTransactor() + + private val observeMessageExpirationTime = ObserveMessageExpirationTime( + draftStateRepository = draftStateRepository, + messageExpirationTimeRepository = messageExpirationTimeRepository, + transactor = transactor + ) + + @Test + fun `should return expiration time for message id when expiration time is emitted`() = runTest { + // Given + expectApiMessageIdDoesNotExist() + coEvery { + messageExpirationTimeRepository.observeMessageExpirationTime(userId, messageId) + } returns flowOf(messageExpirationTime) + + // When + observeMessageExpirationTime(userId, messageId).test { + // Then + assertEquals(messageExpirationTime, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return expiration time for api message id when expiration time is emitted`() = runTest { + // Given + expectApiMessageIdExists() + coEvery { + messageExpirationTimeRepository.observeMessageExpirationTime(userId, apiMessageId) + } returns flowOf(messageExpirationTime) + + // When + observeMessageExpirationTime(userId, messageId).test { + // Then + assertEquals(messageExpirationTime, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return null when message expiration time does not exist`() = runTest { + // Given + expectApiMessageIdDoesNotExist() + coEvery { messageExpirationTimeRepository.observeMessageExpirationTime(userId, messageId) } returns flowOf(null) + + // When + observeMessageExpirationTime(userId, messageId).test { + // Then + assertNull(awaitItem()) + awaitComplete() + } + } + + private fun expectApiMessageIdExists() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = apiMessageId, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } + + private fun expectApiMessageIdDoesNotExist() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = null, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePasswordTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePasswordTest.kt new file mode 100644 index 0000000000..ca074cf4dc --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveMessagePasswordTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.keystore.KeyStoreCrypto +import me.proton.core.crypto.common.pgp.exception.CryptoException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ObserveMessagePasswordTest { + + private val userId = UserIdTestData.userId + private val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val apiMessageId = MessageId("apiMessageId") + + private val draftStateRepository = mockk() + private val keyStoreCrypto = mockk() + private val messagePasswordRepository = mockk() + private val transactor = FakeTransactor() + + private val observeMessagePassword = ObserveMessagePassword( + draftStateRepository = draftStateRepository, + keyStoreCrypto = keyStoreCrypto, + messagePasswordRepository = messagePasswordRepository, + transactor = transactor + ) + + @Test + fun `should return decrypted password for message id when password is emitted and decryption is successful`() = + runTest { + // Given + val encryptedPassword = "encryptedPassword" + val decryptedPassword = "decryptedPassword" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, encryptedPassword, hint) + expectApiMessageIdDoesNotExist() + coEvery { + messagePasswordRepository.observeMessagePassword(userId, messageId) + } returns flowOf(messagePassword) + every { keyStoreCrypto.decrypt(encryptedPassword) } returns decryptedPassword + + // When + observeMessagePassword(userId, messageId).test { + // Then + val expected = messagePassword.copy(password = decryptedPassword) + assertEquals(expected, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return decrypted password for api message id when password is emitted and decryption is successful`() = + runTest { + // Given + val encryptedPassword = "encryptedPassword" + val decryptedPassword = "decryptedPassword" + val hint = "hint" + val messagePassword = MessagePassword(userId, apiMessageId, encryptedPassword, hint) + expectApiMessageIdExists() + coEvery { + messagePasswordRepository.observeMessagePassword(userId, apiMessageId) + } returns flowOf(messagePassword) + every { keyStoreCrypto.decrypt(encryptedPassword) } returns decryptedPassword + + // When + observeMessagePassword(userId, messageId).test { + // Then + val expected = messagePassword.copy(password = decryptedPassword) + assertEquals(expected, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return null when password is emitted but was not decrypted successfully`() = runTest { + // Given + val encryptedPassword = "encryptedPassword" + val hint = "hint" + val messagePassword = MessagePassword(userId, messageId, encryptedPassword, hint) + expectApiMessageIdDoesNotExist() + coEvery { messagePasswordRepository.observeMessagePassword(userId, messageId) } returns flowOf(messagePassword) + every { keyStoreCrypto.decrypt(encryptedPassword) } throws CryptoException() + + // When + observeMessagePassword(userId, messageId).test { + // Then + assertNull(awaitItem()) + awaitComplete() + } + } + + @Test + fun `should return null when message password does not exist`() = runTest { + // Given + expectApiMessageIdDoesNotExist() + coEvery { messagePasswordRepository.observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + observeMessagePassword(userId, messageId).test { + // Then + assertNull(awaitItem()) + awaitComplete() + } + } + + private fun expectApiMessageIdExists() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = apiMessageId, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } + + private fun expectApiMessageIdDoesNotExist() { + coEvery { + draftStateRepository.observe(userId, messageId) + } returns flowOf( + DraftState( + userId = userId, + messageId = messageId, + apiMessageId = null, + state = DraftSyncState.Synchronized, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ).right() + ) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatusTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatusTest.kt new file mode 100644 index 0000000000..c0c5e62cea --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ObserveSendingMessagesStatusTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailcomposer.domain.model.MessageSendingStatus +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Assert.assertEquals +import org.junit.Test + +class ObserveSendingMessagesStatusTest { + + private val draftStateRepository = mockk() + + private val observeSendingMessageState = ObserveSendingMessagesStatus(draftStateRepository) + + @Test + fun `when there are draft that failed sending then emit error sending messages`() = runTest { + // Given + val userId = UserIdSample.Primary + val errorSendingDraftState = DraftStateSample.RemoteDraftInErrorSendingState + expectedDraftStates(userId) { listOf(DraftStateSample.RemoteDraftInSendingState, errorSendingDraftState) } + + // When + val actual = observeSendingMessageState.invoke(userId).first() + + // Then + assertEquals(MessageSendingStatus.SendMessageError, actual) + } + + @Test + fun `when there are drafts that failed to upload attachments then emit error upload attachment status`() = runTest { + // Given + val userId = UserIdSample.Primary + val errorUploadingAttachmentsDraftState = DraftStateSample.RemoteDraftInErrorAttachmentUploadState + expectedDraftStates(userId) { + listOf(DraftStateSample.RemoteDraftInSendingState, errorUploadingAttachmentsDraftState) + } + + // When + val actual = observeSendingMessageState.invoke(userId).first() + + // Then + assertEquals(MessageSendingStatus.UploadAttachmentsError, actual) + } + + @Test + fun `when there are draft that succeeded sending then emit messages sent status`() = runTest { + // Given + val userId = UserIdSample.Primary + val sentDraftState = DraftStateSample.RemoteDraftInSentState + expectedDraftStates(userId) { listOf(DraftStateSample.RemoteDraftInSendingState, sentDraftState) } + + // When + val actual = observeSendingMessageState.invoke(userId).first() + + // Then + assertEquals(MessageSendingStatus.MessageSent, actual) + } + + @Test + fun `when there are both failed and succeeded draft states then emit error sending status`() = runTest { + // Given + val userId = UserIdSample.Primary + val sentDraftState = DraftStateSample.RemoteDraftInSentState + val errorSendingDraftState = DraftStateSample.RemoteDraftInErrorSendingState + expectedDraftStates(userId) { + listOf(DraftStateSample.RemoteDraftInSendingState, sentDraftState, errorSendingDraftState) + } + + // When + val actual = observeSendingMessageState.invoke(userId).first() + + // Then + assertEquals(MessageSendingStatus.SendMessageError, actual) + } + + @Test + fun `when there are no draft states sent or failed then emit None`() = runTest { + // Given + val userId = UserIdSample.Primary + expectedDraftStates(userId) { listOf(DraftStateSample.RemoteDraftInSendingState) } + + // When + val actual = observeSendingMessageState.invoke(userId).first() + + // Then + assertEquals(MessageSendingStatus.None, actual) + } + + private fun expectedDraftStates(userId: UserId, states: () -> List): List = states().also { + coEvery { draftStateRepository.observeAll(userId) } returns flowOf(it) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBodyTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBodyTest.kt new file mode 100644 index 0000000000..254450a3ae --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/PrepareAndEncryptDraftBodyTest.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Sender +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.usecase.ConvertPlainTextIntoHtml +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class PrepareAndEncryptDraftBodyTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val encryptDraftBodyMock = mockk() + private val getLocalDraftMock = mockk() + private val resolveUserAddressMock = mockk() + private val convertPlainTextIntoHtml = mockk() + + private val prepareAndEncryptDraftBody = PrepareAndEncryptDraftBody( + getLocalDraftMock, + resolveUserAddressMock, + convertPlainTextIntoHtml, + encryptDraftBodyMock + ) + + @Test + fun `should prepare a draft with encrypted body and sender details`() = runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val expectedUserId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val existingDraft = expectedGetLocalDraft(expectedUserId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedEncryptedDraftBody = expectedEncryptedDraftBody(plaintextDraftBody, senderAddress) { + DraftBody("I am encrypted") + } + val expectedUpdatedDraft = existingDraft.copy( + message = existingDraft.message.copy( + sender = Sender(senderAddress.email, senderAddress.displayName!!), + addressId = senderAddress.addressId + ), + messageBody = existingDraft.messageBody.copy( + body = expectedEncryptedDraftBody.value + ) + ) + expectedResolvedUserAddress(expectedUserId, senderEmail) { senderAddress } + + // When + val actualEither = prepareAndEncryptDraftBody( + expectedUserId, draftMessageId, plaintextDraftBody, NoQuotedHtmlBody, senderEmail + ) + + // Then + assertEquals(expectedUpdatedDraft, actualEither.getOrNull()) + } + + @Test + fun `should wrap draft text in 'pre', append quoted html body and set mime type to html when any quote exists`() = + runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val quotedHtmlBody = OriginalHtmlQuote("
I am quoted html
") + val htmlDraftBody = expectedConvertedText(plaintextDraftBody.value, plaintextDraftBody.value) + val expectedMergedBody = DraftBody("$htmlDraftBody${quotedHtmlBody.value}") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val expectedUserId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val existingDraft = expectedGetLocalDraft(expectedUserId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedEncryptedDraftBody = expectedEncryptedDraftBody(expectedMergedBody, senderAddress) { + DraftBody("I am encrypted with the quoted html included") + } + val expectedUpdatedDraft = existingDraft.copy( + message = existingDraft.message.copy( + sender = Sender(senderAddress.email, senderAddress.displayName!!), + addressId = senderAddress.addressId + ), + messageBody = existingDraft.messageBody.copy( + body = expectedEncryptedDraftBody.value, + mimeType = MimeType.Html + ) + ) + expectedResolvedUserAddress(expectedUserId, senderEmail) { senderAddress } + + // When + val actualEither = prepareAndEncryptDraftBody( + expectedUserId, draftMessageId, plaintextDraftBody, quotedHtmlBody, senderEmail + ) + + // Then + assertEquals(expectedUpdatedDraft, actualEither.getOrNull()) + } + + @Test + fun `should return error and not save draft when encryption fails`() = runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + expectedGetLocalDraft(userId, draftMessageId, senderEmail) { MessageWithBodySample.EmptyDraft } + expectedResolvedUserAddress(userId, senderEmail) { senderAddress } + givenDraftBodyEncryptionFails() + + // When + val actualEither = prepareAndEncryptDraftBody( + userId, draftMessageId, plaintextDraftBody, NoQuotedHtmlBody, senderEmail + ) + + // Then + assertEquals(PrepareDraftBodyError.DraftBodyEncryptionError.left(), actualEither) + loggingTestRule.assertErrorLogged("Encrypt draft $draftMessageId body to store to local DB failed") + } + + @Test + fun `should return error when resolving the draft sender address fails`() = runTest { + // Given + val expectedSenderEmail = SenderEmail("unresolvable@sender.email") + val expectedUserId = UserIdSample.Primary + val expectedDraftBody = DraftBody("I am plaintext") + val draftMessageId = MessageIdSample.build() + expectedGetLocalDraft(expectedUserId, draftMessageId, expectedSenderEmail) { MessageWithBodySample.EmptyDraft } + expectResolveUserAddressFailure(expectedUserId, expectedSenderEmail) + + // When + val actualEither = prepareAndEncryptDraftBody( + expectedUserId, draftMessageId, expectedDraftBody, NoQuotedHtmlBody, expectedSenderEmail + ) + + // Then + assertEquals(PrepareDraftBodyError.DraftResolveUserAddressError.left(), actualEither) + } + + @Test + fun `should return error when reading the local draft fails`() = runTest { + // Given + val expectedSenderEmail = SenderEmail("unresolvable@sender.email") + val expectedUserId = UserIdSample.Primary + val expectedDraftBody = DraftBody("I am plaintext") + val draftMessageId = MessageIdSample.build() + expectedResolvedUserAddress(expectedUserId, expectedSenderEmail) { UserAddressSample.PrimaryAddress } + expectedGetLocalDraftFails(expectedUserId, draftMessageId, expectedSenderEmail) { + GetLocalDraft.Error.ResolveUserAddressError + } + + // When + val actualEither = prepareAndEncryptDraftBody( + expectedUserId, draftMessageId, expectedDraftBody, NoQuotedHtmlBody, expectedSenderEmail + ) + + // Then + assertEquals(PrepareDraftBodyError.DraftReadError.left(), actualEither) + } + + private fun expectedConvertedText(original: String, expected: String): String { + every { convertPlainTextIntoHtml(original) } returns expected + return expected + } + + private fun expectedGetLocalDraft( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + localDraft: () -> MessageWithBody + ): MessageWithBody = localDraft().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.right() + } + + private fun expectedGetLocalDraftFails( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + error: () -> GetLocalDraft.Error + ): GetLocalDraft.Error = error().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.left() + } + + private fun expectedEncryptedDraftBody( + plaintextDraftBody: DraftBody, + senderAddress: UserAddress, + expectedEncryptedDraftBody: () -> DraftBody + ): DraftBody = expectedEncryptedDraftBody().also { + coEvery { encryptDraftBodyMock(plaintextDraftBody, senderAddress) } returns it.right() + } + + private fun givenDraftBodyEncryptionFails() { + coEvery { encryptDraftBodyMock(any(), any()) } returns Unit.left() + } + + private fun expectedResolvedUserAddress( + userId: UserId, + email: SenderEmail, + address: () -> UserAddress + ) = address().also { coEvery { resolveUserAddressMock(userId, email.value) } returns it.right() } + + private fun expectResolveUserAddressFailure(userId: UserId, email: SenderEmail) { + coEvery { + resolveUserAddressMock( + userId, + email.value + ) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + } + + companion object { + + private val NoQuotedHtmlBody = null + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftIdTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftIdTest.kt new file mode 100644 index 0000000000..02426fa660 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ProvideNewDraftIdTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import java.util.UUID +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import junit.framework.TestCase.assertEquals +import org.junit.Test + +internal class ProvideNewDraftIdTest { + + private val provideNewDraftId = ProvideNewDraftId() + + @Test + fun `should provide a new draft id`() { + // Given + val expectedDraftRawId = "8236daf9-db4b-45b0-855d-a9676d890b6d" + val expectedDraftMessageId = MessageId(expectedDraftRawId) + mockkStatic(UUID::randomUUID) + every { UUID.randomUUID() } returns UUID.fromString(expectedDraftRawId) + + // When + val actualDraftMessageId = provideNewDraftId() + + // Then + assertEquals(expectedDraftMessageId, actualDraftMessageId) + unmockkStatic(UUID::randomUUID) + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachmentsTest.kt new file mode 100644 index 0000000000..16db360db4 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ReEncryptAttachmentsTest.kt @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.keystore.EncryptedByteArray +import me.proton.core.crypto.common.keystore.PlainByteArray +import me.proton.core.crypto.common.pgp.PGPCrypto +import me.proton.core.crypto.common.pgp.SessionKey +import me.proton.core.crypto.common.pgp.exception.CryptoException +import me.proton.core.key.domain.entity.key.PrivateKey +import me.proton.core.user.domain.entity.AddressId +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.user.domain.entity.UserAddressKey +import org.junit.Test +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.assertEquals + +@ExperimentalEncodingApi +class ReEncryptAttachmentsTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.Invoice + private val previousSender = SenderEmail(UserAddressSample.PrimaryAddress.email) + private val newSender = SenderEmail(UserAddressSample.AliasAddress.email) + + private val mockedSessionKey = SessionKey("mockedSessionKey".encodeToByteArray()) + private val mockedKeyPacket = "encryptedKeyPackets".toByteArray() + private val newMockedKeyPacket = "newEncryptedKeyPackets".toByteArray() + + private val armoredPrivateKey = "armoredPrivateKey" + private val armoredPublicKey = "armoredPublicKey" + private val encryptedPassphrase = EncryptedByteArray("encryptedPassphrase".encodeToByteArray()) + private val decryptedPassphrase = PlainByteArray("decryptedPassPhrase".encodeToByteArray()) + private val unlockedPrivateKey = "unlockedPrivateKey".encodeToByteArray() + + private val userAddressKey = mockk { + every { privateKey } returns PrivateKey( + key = armoredPrivateKey, + isPrimary = true, + isActive = true, + canEncrypt = true, + canVerify = true, + passphrase = encryptedPassphrase + ) + } + + private val userAddress = mockk { + every { keys } returns listOf(userAddressKey) + } + + private val pgpCryptoMock = mockk { + every { unlock(armoredPrivateKey, decryptedPassphrase.array) } returns mockk(relaxUnitFun = true) { + every { value } returns unlockedPrivateKey + } + every { getPublicKey(armoredPrivateKey) } returns armoredPublicKey + every { generateNewSessionKey() } returns mockedSessionKey + every { encryptSessionKey(mockedSessionKey, armoredPublicKey) } returns newMockedKeyPacket + every { decryptSessionKey(mockedKeyPacket, unlockedPrivateKey) } returns mockedSessionKey + } + private val cryptoContextMock = mockk { + every { pgpCrypto } returns pgpCryptoMock + every { keyStoreCrypto } returns mockk { + every { decrypt(encryptedPassphrase) } returns decryptedPassphrase + } + } + + private val getLocalDraft = mockk() + private val messageRepository = mockk() + private val resolveUserAddress = mockk() + private val cryptoContext = cryptoContextMock + private val transactor = FakeTransactor() + + private val reEncryptAttachments by lazy { + ReEncryptAttachments( + getLocalDraft, + messageRepository, + resolveUserAddress, + cryptoContext, + transactor + ) + } + + @Test + fun `when the reencryption is successful then the message is updated with the new keypackets`() = runTest { + // Given + val initialMessageWithBody = MessageWithBodySample.MessageWithEncryptedAttachments.copy( + message = MessageWithBodySample.MessageWithEncryptedAttachments.message.copy( + addressId = AddressId(newSender.value) + ) + ) + val attachmentSize = initialMessageWithBody.messageBody.attachments.size + val updatedMessageWithBody = initialMessageWithBody.copy( + messageBody = initialMessageWithBody.messageBody.copy( + attachments = initialMessageWithBody.messageBody.attachments.map { + it.copy(keyPackets = Base64.encode(newMockedKeyPacket)) + } + ) + ) + + expectGetLocalDraftSucceeds(initialMessageWithBody) + expectGetMessageWithBodySucceeds(initialMessageWithBody) + expectResolveUserAddressSucceeds(previousSender) + expectResolveAddressIdSucceeds(AddressId(newSender.value)) + expectUpsertMessageSucceeds(updatedMessageWithBody) + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(Unit.right(), actual) + coVerify(exactly = 1) { getLocalDraft(userId, messageId, newSender) } + coVerify(exactly = 1) { messageRepository.upsertMessageWithBody(userId, updatedMessageWithBody) } + coVerify(exactly = attachmentSize) { pgpCryptoMock.decryptSessionKey(any(), unlockedPrivateKey) } + coVerify(exactly = attachmentSize) { pgpCryptoMock.encryptSessionKey(mockedSessionKey, armoredPublicKey) } + } + + @Test + fun `given the local draft is not found then return draft not found error`() = runTest { + // Given + val expectedDraftError = GetLocalDraft.Error.ResolveUserAddressError.left() + val expectedError = AttachmentReEncryptionError.DraftNotFound.left() + expectGetLocalDraftFails(expectedDraftError) + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + coVerify { messageRepository wasNot Called } + } + + @Test + fun `given the previous sender cannot be resolved then return previous failed to resolve error`() = runTest { + // Given + val expectedError = AttachmentReEncryptionError.FailedToResolvePreviousUserAddress.left() + val expectedMessage = MessageWithBodySample.MessageWithEncryptedAttachments + expectGetLocalDraftSucceeds(expectedMessage) + expectGetMessageWithBodySucceeds(expectedMessage) + expectResolveUserAddressFails() + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + } + + @Test + fun `given the new sender cannot be resolved then return new failed to resolve error`() = runTest { + // Given + val expectedError = AttachmentReEncryptionError.FailedToResolveNewUserAddress.left() + val initialMessageWithBody = MessageWithBodySample.MessageWithEncryptedAttachments.copy( + message = MessageWithBodySample.MessageWithEncryptedAttachments.message.copy( + addressId = AddressId(newSender.value) + ) + ) + + expectGetLocalDraftSucceeds(initialMessageWithBody) + expectGetMessageWithBodySucceeds(initialMessageWithBody) + expectResolveUserAddressSucceeds(previousSender) + expectResolveAddressIdFails(AddressId(newSender.value)) + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + } + + @Test + fun `given the attachment keyPackets cannot be decrypted then return failed to decrypt error`() = runTest { + // Given + val expectedError = AttachmentReEncryptionError.FailedToDecryptAttachmentKeyPackets.left() + val initialMessageWithBody = MessageWithBodySample.MessageWithEncryptedAttachments.copy( + message = MessageWithBodySample.MessageWithEncryptedAttachments.message.copy( + addressId = AddressId(newSender.value) + ) + ) + + expectGetLocalDraftSucceeds(initialMessageWithBody) + expectGetMessageWithBodySucceeds(initialMessageWithBody) + expectResolveUserAddressSucceeds(previousSender) + expectResolveAddressIdSucceeds(AddressId(newSender.value)) + expectDecryptSessionKeyFails() + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + coVerify(exactly = 1) { pgpCryptoMock.decryptSessionKey(any(), unlockedPrivateKey) } + } + + @Test + fun `given the attachment keyPackets cannot be encrypted then return failed to encrypt error`() = runTest { + // Given + val expectedError = AttachmentReEncryptionError.FailedToEncryptAttachmentKeyPackets.left() + val initialMessageWithBody = MessageWithBodySample.MessageWithEncryptedAttachments.copy( + message = MessageWithBodySample.MessageWithEncryptedAttachments.message.copy( + addressId = AddressId(newSender.value) + ) + ) + + expectGetLocalDraftSucceeds(initialMessageWithBody) + expectGetMessageWithBodySucceeds(initialMessageWithBody) + expectResolveUserAddressSucceeds(previousSender) + expectResolveAddressIdSucceeds(AddressId(newSender.value)) + expectEncryptSessionKeyFails() + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + coVerify(exactly = 1) { pgpCryptoMock.encryptSessionKey(mockedSessionKey, armoredPublicKey) } + } + + @Test + fun `given the attachment keyPackets cannot be updated then return failed to update error`() = runTest { + // Given + val expectedError = AttachmentReEncryptionError.FailedToUpdateAttachmentKeyPackets.left() + val initialMessageWithBody = MessageWithBodySample.MessageWithEncryptedAttachments.copy( + message = MessageWithBodySample.MessageWithEncryptedAttachments.message.copy( + addressId = AddressId(newSender.value) + ) + ) + + expectGetLocalDraftSucceeds(initialMessageWithBody) + expectGetMessageWithBodySucceeds(initialMessageWithBody) + expectResolveUserAddressSucceeds(previousSender) + expectResolveAddressIdSucceeds(AddressId(newSender.value)) + expectUpsertMessageFails( + initialMessageWithBody.copy( + messageBody = initialMessageWithBody.messageBody.copy( + attachments = initialMessageWithBody.messageBody.attachments.map { + it.copy(keyPackets = Base64.encode(newMockedKeyPacket)) + } + ) + ) + ) + + // When + val actual = reEncryptAttachments(userId, messageId, previousSender, newSender) + + // Then + assertEquals(expectedError, actual) + } + + private fun expectGetLocalDraftSucceeds(expectedMessageWithBody: MessageWithBody) { + coEvery { getLocalDraft(userId, messageId, newSender) } returns expectedMessageWithBody.copy( + message = expectedMessageWithBody.message.copy( + addressId = AddressId(newSender.value) + ) + ).right() + } + + private fun expectGetLocalDraftFails( + expectedDraftError: Either + ) { + coEvery { getLocalDraft(userId, messageId, newSender) } returns expectedDraftError + } + + private fun expectGetMessageWithBodySucceeds(expectedMessageWithBody: MessageWithBody) { + coEvery { + messageRepository.getMessageWithBody(userId, expectedMessageWithBody.message.messageId) + } returns expectedMessageWithBody.right() + } + + private fun expectResolveUserAddressSucceeds(senderEmail: SenderEmail) { + coEvery { resolveUserAddress(userId, senderEmail.value) } returns userAddress.right() + } + + private fun expectResolveUserAddressFails() { + coEvery { + resolveUserAddress(userId, previousSender.value) + } returns ResolveUserAddress.Error.UserAddressNotFound.left() + } + + private fun expectResolveAddressIdSucceeds(addressId: AddressId) { + coEvery { resolveUserAddress(userId, addressId) } returns userAddress.right() + } + + private fun expectResolveAddressIdFails(addressId: AddressId) { + coEvery { resolveUserAddress(userId, addressId) } returns ResolveUserAddress.Error.UserAddressNotFound.left() + } + + private fun expectUpsertMessageSucceeds(messageWithBody: MessageWithBody) { + coEvery { messageRepository.upsertMessageWithBody(userId, messageWithBody) } returns true + } + + private fun expectUpsertMessageFails(messageWithBody: MessageWithBody) { + coEvery { messageRepository.upsertMessageWithBody(userId, messageWithBody) } returns false + } + + private fun expectDecryptSessionKeyFails() { + every { pgpCryptoMock.decryptSessionKey(any(), unlockedPrivateKey) } throws CryptoException("Failed to decrypt") + } + + private fun expectEncryptSessionKeyFails() { + every { + pgpCryptoMock.encryptSessionKey(mockedSessionKey, armoredPublicKey) + } throws CryptoException("Failed to encrypt") + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateErrorTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateErrorTest.kt new file mode 100644 index 0000000000..bebd92db7e --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetDraftStateErrorTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ResetDraftStateErrorTest { + + private val draftStateRepository = mockk() + + private val resetDraftStateError = ResetDraftStateError(draftStateRepository) + + @Test + fun `reset error state sets the draft state to Syncronized`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = DraftStateSample.RemoteDraftInErrorSendingState.messageId + coJustRun { draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Synchronized) } + + // When + resetDraftStateError.invoke(userId, messageId) + + // Then + coVerify { draftStateRepository.updateDraftSyncState(userId, messageId, DraftSyncState.Synchronized) } + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatusTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatusTest.kt new file mode 100644 index 0000000000..6ba977cd88 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResetSendingMessagesStatusTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.DraftStateSample +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ResetSendingMessagesStatusTest { + + private val draftStateRepository = mockk() + + private val resetDraftStateError = mockk() + private val confirmSendingMessageStatus = mockk() + + private val resetSendingMessagesStatus = ResetSendingMessagesStatus( + draftStateRepository, resetDraftStateError, confirmSendingMessageStatus + ) + + @Test + fun `should reset error state and confirm sending status if current state is error sending `() = runTest { + // Given + val userId = DraftStateSample.RemoteDraftInErrorSendingState.userId + val messageId = DraftStateSample.RemoteDraftInErrorSendingState.messageId + coEvery { draftStateRepository.observeAll(userId) } returns + flowOf(listOf(DraftStateSample.RemoteDraftInErrorSendingState)) + coJustRun { resetDraftStateError.invoke(userId, messageId) } + coJustRun { confirmSendingMessageStatus.invoke(userId, messageId) } + + // When + resetSendingMessagesStatus.invoke(userId) + + // Then + coVerify { resetDraftStateError.invoke(userId, messageId) } + coVerify { confirmSendingMessageStatus.invoke(userId, messageId) } + + } + + @Test + fun `should reset error state and confirm sending status if current state is error uploading attachment`() = + runTest { + // Given + val userId = DraftStateSample.RemoteDraftInErrorAttachmentUploadState.userId + val messageId = DraftStateSample.RemoteDraftInErrorAttachmentUploadState.messageId + coEvery { draftStateRepository.observeAll(userId) } returns + flowOf(listOf(DraftStateSample.RemoteDraftInErrorAttachmentUploadState)) + coJustRun { resetDraftStateError.invoke(userId, messageId) } + coJustRun { confirmSendingMessageStatus.invoke(userId, messageId) } + + // When + resetSendingMessagesStatus.invoke(userId) + + // Then + coVerify { resetDraftStateError.invoke(userId, messageId) } + coVerify { confirmSendingMessageStatus.invoke(userId, messageId) } + } + + @Test + fun `should confirm confirm sending status if current state is sent`() = runTest { + // Given + val userId = DraftStateSample.RemoteDraftInSentState.userId + val messageId = DraftStateSample.RemoteDraftInSentState.messageId + coEvery { draftStateRepository.observeAll(userId) } returns + flowOf(listOf(DraftStateSample.RemoteDraftInSentState)) + coJustRun { confirmSendingMessageStatus.invoke(userId, messageId) } + + // When + resetSendingMessagesStatus.invoke(userId) + + // Then + coVerify { confirmSendingMessageStatus.invoke(userId, messageId) } + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResolveUserAddressTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResolveUserAddressTest.kt new file mode 100644 index 0000000000..de7876d730 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ResolveUserAddressTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.mailcommon.domain.usecase.ResolveUserAddress +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +class ResolveUserAddressTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val userId = UserIdTestData.userId + private val userAddresses = listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) + + private val observeUserAddresses = mockk { + every { this@mockk(userId) } returns flowOf(userAddresses) + } + + private val resolveUserAddress = ResolveUserAddress(observeUserAddresses) + + @Test + fun `returns user address by email when found in user addresses`() = runTest { + // Given + val expectedUserAddress = UserAddressSample.AliasAddress + + // When + val actual = resolveUserAddress(userId, expectedUserAddress.email) + + // Then + assertEquals(expectedUserAddress.right(), actual) + } + + @Test + fun `returns error when user address was not found by email`() = runTest { + // Given + val expectedResult = ResolveUserAddress.Error.UserAddressNotFound + val notFoundUserAddress = UserAddressSample.DisabledAddress + + // When + val actual = resolveUserAddress(userId, notFoundUserAddress.email) + + // Then + assertEquals(expectedResult.left(), actual) + loggingTestRule.assertErrorLogged("Could not resolve user address for email: ${notFoundUserAddress.email}") + } + + @Test + fun `returns user address by address ID when found in user addresses`() = runTest { + // Given + val expectedUserAddress = UserAddressSample.AliasAddress + + // When + val actual = resolveUserAddress(userId, expectedUserAddress.addressId) + + // Then + assertEquals(expectedUserAddress.right(), actual) + } + + @Test + fun `returns error when user address was not found by address ID`() = runTest { + // Given + val expectedResult = ResolveUserAddress.Error.UserAddressNotFound + val notFoundUserAddress = UserAddressSample.DisabledAddress + + // When + val actual = resolveUserAddress(userId, notFoundUserAddress.addressId) + + // Then + assertEquals(expectedResult.left(), actual) + loggingTestRule + .assertErrorLogged("Could not resolve user address for address ID: ${notFoundUserAddress.addressId.id}") + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraftTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraftTest.kt new file mode 100644 index 0000000000..0f44d6749f --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveDraftTest.kt @@ -0,0 +1,55 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SaveDraftTest { + + private val messageRepositoryMock = mockk(relaxUnitFun = true) + private val saveDraft = SaveDraft(messageRepositoryMock) + + @Test + fun `should upsert message with body when saving the draft`() = runTest { + // Given + val expectedUserId = UserIdSample.Primary + val expectedMessageWithBody = MessageWithBodySample.EmptyDraft + givenRepositorySucceeds(expectedMessageWithBody, expectedUserId) + + // When + val draftSaved = saveDraft(expectedMessageWithBody, expectedUserId) + + // Then + assertTrue(draftSaved) + } + + @Test + fun `should return false when saving a draft fails`() = runTest { + // Given + val expectedUserId = UserIdSample.Primary + val expectedMessageWithBody = MessageWithBodySample.EmptyDraft + givenRepositoryFails(expectedMessageWithBody, expectedUserId) + + // When + val draftSaved = saveDraft(expectedMessageWithBody, expectedUserId) + + // Then + assertFalse(draftSaved) + } + + private fun givenRepositoryFails(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { messageRepositoryMock.upsertMessageWithBody(userId, messageWithBody) } returns false + } + + private fun givenRepositorySucceeds(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { messageRepositoryMock.upsertMessageWithBody(userId, messageWithBody) } returns true + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTimeTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTimeTest.kt new file mode 100644 index 0000000000..3b8e3eabd9 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessageExpirationTimeTest.kt @@ -0,0 +1,142 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.MessageExpirationTimeRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days + +class SaveMessageExpirationTimeTest { + + private val userId = UserIdTestData.userId + private val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val senderEmail = SenderEmail("sender@pm.me") + private val expiresIn = 1.days + + private val getLocalDraft = mockk() + private val messageExpirationTimeRepository = mockk() + private val messageRepository = mockk() + private val saveDraft = mockk() + private val transactor = FakeTransactor() + + private val saveMessageExpirationTime = SaveMessageExpirationTime( + getLocalDraft, messageExpirationTimeRepository, messageRepository, saveDraft, transactor + ) + + @Test + fun `should return unit when draft exists and message expiration time stored successfully`() = runTest { + // Given + expectDraftAlreadyExists() + coEvery { + messageExpirationTimeRepository.saveMessageExpirationTime( + MessageExpirationTime( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + expiresIn + ) + ) + } returns Unit.right() + + // When + val actual = saveMessageExpirationTime(userId, messageId, senderEmail, expiresIn) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return unit when draft is saved and message expiration time is stored successfully`() = runTest { + // Given + expectDraftDoesNotExist() + coEvery { saveDraft(MessageWithBodySample.EmptyDraft, userId) } returns true + coEvery { + messageExpirationTimeRepository.saveMessageExpirationTime( + MessageExpirationTime( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + expiresIn + ) + ) + } returns Unit.right() + + // When + val actual = saveMessageExpirationTime(userId, messageId, senderEmail, expiresIn) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when getting the local draft has failed `() = runTest { + // Given + coEvery { + getLocalDraft(userId, messageId, senderEmail) + } returns GetLocalDraft.Error.ResolveUserAddressError.left() + + // When + val actual = saveMessageExpirationTime(userId, messageId, senderEmail, expiresIn) + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + + @Test + fun `should return error when saving draft has failed`() = runTest { + // Given + expectDraftDoesNotExist() + coEvery { saveDraft(MessageWithBodySample.EmptyDraft, userId) } returns false + + // When + val actual = saveMessageExpirationTime(userId, messageId, senderEmail, expiresIn) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + @Test + fun `should return error when saving of message expiration time fails`() = runTest { + // Given + expectDraftAlreadyExists() + coEvery { + messageExpirationTimeRepository.saveMessageExpirationTime( + MessageExpirationTime( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + expiresIn + ) + ) + } returns DataError.Local.Unknown.left() + + // When + val actual = saveMessageExpirationTime(userId, messageId, senderEmail, expiresIn) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + private fun expectDraftAlreadyExists() { + coEvery { getLocalDraft(userId, messageId, senderEmail) } returns MessageWithBodySample.EmptyDraft.right() + coEvery { + messageRepository.getLocalMessageWithBody(userId, MessageWithBodySample.EmptyDraft.message.messageId) + } returns MessageWithBodySample.EmptyDraft + } + + private fun expectDraftDoesNotExist() { + coEvery { getLocalDraft(userId, messageId, senderEmail) } returns MessageWithBodySample.EmptyDraft.right() + coEvery { + messageRepository.getLocalMessageWithBody(userId, MessageWithBodySample.EmptyDraft.message.messageId) + } returns null + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePasswordTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePasswordTest.kt new file mode 100644 index 0000000000..e39f18c99f --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/SaveMessagePasswordTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.MessagePasswordRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.crypto.common.keystore.KeyStoreCrypto +import me.proton.core.crypto.common.pgp.exception.CryptoException +import kotlin.test.Test +import kotlin.test.assertEquals + +class SaveMessagePasswordTest { + + private val userId = UserIdTestData.userId + private val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val senderEmail = SenderEmail("sender@pm.me") + + private val getLocalDraft = mockk() + private val keyStoreCrypto = mockk() + private val messagePasswordRepository = mockk() + private val messageRepository = mockk() + private val saveDraft = mockk() + private val transactor = FakeTransactor() + + private val saveMessagePassword = SaveMessagePassword( + getLocalDraft = getLocalDraft, + keyStoreCrypto = keyStoreCrypto, + messagePasswordRepository = messagePasswordRepository, + messageRepository = messageRepository, + saveDraft = saveDraft, + transactor = transactor + ) + + @Test + fun `should return unit when draft exists and message password is encrypted and stored successfully`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val encryptedPassword = "encryptedPassword" + expectDraftAlreadyExists() + every { keyStoreCrypto.encrypt(password) } returns encryptedPassword + coEvery { + messagePasswordRepository.saveMessagePassword( + MessagePassword( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + encryptedPassword, + passwordHint + ) + ) + } returns Unit.right() + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return unit when draft is saved and message password is encrypted and stored successfully`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val encryptedPassword = "encryptedPassword" + expectDraftDoesNotExist() + coEvery { saveDraft(MessageWithBodySample.EmptyDraft, userId) } returns true + every { keyStoreCrypto.encrypt(password) } returns encryptedPassword + coEvery { + messagePasswordRepository.saveMessagePassword( + MessagePassword( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + encryptedPassword, + passwordHint + ) + ) + } returns Unit.right() + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when getting the local draft has failed `() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + coEvery { + getLocalDraft(userId, messageId, senderEmail) + } returns GetLocalDraft.Error.ResolveUserAddressError.left() + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(DataError.Local.NoDataCached.left(), actual) + } + + @Test + fun `should return error when saving draft has failed`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + expectDraftDoesNotExist() + coEvery { saveDraft(MessageWithBodySample.EmptyDraft, userId) } returns false + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + @Test + fun `should return encryption error when password encryption fails`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + expectDraftAlreadyExists() + every { keyStoreCrypto.encrypt(password) } throws CryptoException() + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(DataError.Local.EncryptionError.left(), actual) + } + + @Test + fun `should return error when saving of encrypted password fails`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val encryptedPassword = "encryptedPassword" + expectDraftAlreadyExists() + every { keyStoreCrypto.encrypt(password) } returns encryptedPassword + coEvery { + messagePasswordRepository.saveMessagePassword( + MessagePassword( + userId, + MessageWithBodySample.EmptyDraft.message.messageId, + encryptedPassword, + passwordHint + ) + ) + } returns DataError.Local.Unknown.left() + + // When + val actual = saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + @Test + fun `should return unit when message password is encrypted and updated successfully`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val encryptedPassword = "encryptedPassword" + expectDraftAlreadyExists() + every { keyStoreCrypto.encrypt(password) } returns encryptedPassword + coEvery { + messagePasswordRepository.updateMessagePassword( + userId, MessageWithBodySample.EmptyDraft.message.messageId, encryptedPassword, passwordHint + ) + } returns Unit.right() + + // When + val actual = saveMessagePassword( + userId, messageId, senderEmail, password, passwordHint, SaveMessagePasswordAction.Update + ) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `should return error when updating of encrypted password fails`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val encryptedPassword = "encryptedPassword" + expectDraftAlreadyExists() + every { keyStoreCrypto.encrypt(password) } returns encryptedPassword + coEvery { + messagePasswordRepository.updateMessagePassword( + userId, MessageWithBodySample.EmptyDraft.message.messageId, encryptedPassword, passwordHint + ) + } returns DataError.Local.Unknown.left() + + // When + val actual = saveMessagePassword( + userId, messageId, senderEmail, password, passwordHint, SaveMessagePasswordAction.Update + ) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + private fun expectDraftAlreadyExists() { + coEvery { getLocalDraft(userId, messageId, senderEmail) } returns MessageWithBodySample.EmptyDraft.right() + coEvery { + messageRepository.getLocalMessageWithBody(userId, MessageWithBodySample.EmptyDraft.message.messageId) + } returns MessageWithBodySample.EmptyDraft + } + + private fun expectDraftDoesNotExist() { + coEvery { getLocalDraft(userId, messageId, senderEmail) } returns MessageWithBodySample.EmptyDraft.right() + coEvery { + messageRepository.getLocalMessageWithBody(userId, MessageWithBodySample.EmptyDraft.message.messageId) + } returns null + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachmentsTest.kt new file mode 100644 index 0000000000..3ebe80bfb3 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreAttachmentsTest.kt @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import android.net.Uri +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.usecase.ProvideNewAttachmentId +import ch.protonmail.android.test.utils.FakeTransactor +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreAttachmentsTest { + + private val userId = UserIdSample.Primary + private val senderAddress = UserAddressSample.build() + private val senderEmail = SenderEmail(senderAddress.email) + private val localMessageId = MessageId("localMessageId") + private val localAttachmentId = AttachmentId("localAttachmentId") + + private val uri = mockk() + private val messageRepository = mockk() + private val attachmentRepository = mockk() + private val attachmentStateRepository = mockk() + private val getLocalDraft = mockk() + private val saveDraft = mockk() + private val provideNewAttachmentId = mockk { + every { this@mockk.invoke() } returns localAttachmentId + } + private val transactor = FakeTransactor() + + private val storeAttachments = StoreAttachments( + messageRepository, + attachmentRepository, + attachmentStateRepository, + getLocalDraft, + saveDraft, + provideNewAttachmentId, + transactor + ) + + @Test + fun `should save draft and attachment with id retrieved from local draft`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + val expectedMessageBody = MessageWithBodySample.Invoice + expectedLocalDraft(expectedMessageId, expectedMessageBody) + expectedLocalMessageBody(expectedMessageId, null) + expectedDraftSaving(expectedMessageBody, true) + expectAttachmentSavingSuccessful(expectedMessageId) + expectAttachmentStateSavingSuccess(expectedMessageId) + expectGetFileSizeFromUriSuccess() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(Unit.right(), actual) + coVerify { saveDraft(expectedMessageBody, userId) } + coVerify { attachmentRepository.saveAttachment(userId, expectedMessageId, localAttachmentId, uri) } + } + + @Test + fun `should save attachment with id retrieved from existing local draft`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + val expectedMessageBody = MessageWithBodySample.Invoice + expectedLocalDraft(expectedMessageId, expectedMessageBody) + expectedLocalMessageBody(expectedMessageId, expectedMessageBody) + expectAttachmentSavingSuccessful(expectedMessageId) + expectAttachmentStateSavingSuccess(expectedMessageId) + expectGetFileSizeFromUriSuccess() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(Unit.right(), actual) + coVerify { saveDraft wasNot Called } + coVerify { attachmentRepository.saveAttachment(userId, expectedMessageId, localAttachmentId, uri) } + } + + @Test + fun `should return failed receiving draft when storing draft fails`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + val expectedMessageBody = MessageWithBodySample.Invoice + val expectedError = StoreDraftWithAttachmentError.FailedReceivingDraft.left() + + expectedLocalDraft(expectedMessageId, expectedMessageBody) + expectedLocalMessageBody(expectedMessageId, null) + expectAttachmentSavingSuccessful(expectedMessageId) + expectedDraftSaving(expectedMessageBody, false) + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(expectedError, actual) + coVerify { saveDraft(expectedMessageBody, userId) } + coVerify { attachmentRepository wasNot Called } + coVerify { attachmentStateRepository wasNot Called } + } + + @Test + fun `should return failed receiving draft when get local draft fails`() = runTest { + // Given + expectedLocalDraftError() + val expectedError = StoreDraftWithAttachmentError.FailedReceivingDraft.left() + + // When + val actual = storeAttachments(userId, localMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(expectedError, actual) + coVerify { messageRepository wasNot Called } + coVerify { attachmentRepository wasNot Called } + coVerify { attachmentStateRepository wasNot Called } + } + + @Test + fun `should return failed to store attachment when storing returns error`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + expectedLocalDraft(expectedMessageId, MessageWithBodySample.Invoice) + expectedLocalMessageBody(expectedMessageId, MessageWithBodySample.Invoice) + expectAttachmentSavingFailed(expectedMessageId, uri) + expectGetFileSizeFromUriSuccess() + + val expectedError = StoreDraftWithAttachmentError.FailedToStoreAttachments.left() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(expectedError, actual) + } + + @Test + fun `should return failed to store attachment when one URI withing a list failed to get stored`() = runTest { + // Given + val uri2 = mockk() + val expectedMessageId = MessageIdSample.Invoice + expectedLocalDraft(expectedMessageId, MessageWithBodySample.Invoice) + expectedLocalMessageBody(expectedMessageId, MessageWithBodySample.Invoice) + expectAttachmentSavingSuccessful(expectedMessageId) + expectAttachmentSavingFailed(expectedMessageId, uri2) + expectAttachmentStateSavingSuccess(expectedMessageId) + expectGetFileSizeFromUriSuccess() + expectGetFileSizeFromUriSuccess(uri2) + + val expectedError = StoreDraftWithAttachmentError.FailedToStoreAttachments.left() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri, uri2)) + + // Then + assertEquals(expectedError, actual) + } + + @Test + fun `should return file size exceeds limit when file exceeds the limit`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + val expectedMessageBody = MessageWithBodySample.Invoice + expectedLocalDraft(expectedMessageId, expectedMessageBody) + expectedLocalMessageBody(expectedMessageId, null) + expectedDraftSaving(expectedMessageBody, true) + expectAttachmentSavingSuccessful(expectedMessageId) + expectAttachmentStateSavingSuccess(expectedMessageId) + expectGetFileSizeExceedsLimit() + + val expectedError = StoreDraftWithAttachmentError.FileSizeExceedsLimit.left() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(expectedError, actual) + } + + @Test + fun `should return attachment file missing when file size cannot be retrieved`() = runTest { + // Given + val expectedMessageId = MessageIdSample.Invoice + val expectedMessageBody = MessageWithBodySample.Invoice + expectedLocalDraft(expectedMessageId, expectedMessageBody) + expectedLocalMessageBody(expectedMessageId, null) + expectedDraftSaving(expectedMessageBody, true) + expectAttachmentSavingSuccessful(expectedMessageId) + expectAttachmentStateSavingSuccess(expectedMessageId) + expectGetFileSizeFromUriFailed() + + val expectedError = StoreDraftWithAttachmentError.AttachmentFileMissing.left() + + // When + val actual = storeAttachments(userId, expectedMessageId, senderEmail, listOf(uri)) + + // Then + assertEquals(expectedError, actual) + } + + + private fun expectedLocalDraft(expectedMessageId: MessageId, expectedMessageBody: MessageWithBody) { + coEvery { getLocalDraft(userId, expectedMessageId, senderEmail) } returns expectedMessageBody.right() + } + + private fun expectedLocalDraftError() { + coEvery { + getLocalDraft(userId, localMessageId, senderEmail) + } returns GetLocalDraft.Error.ResolveUserAddressError.left() + } + + private fun expectedLocalMessageBody(messageId: MessageId, expectedMessageBody: MessageWithBody?) { + coEvery { messageRepository.getLocalMessageWithBody(userId, messageId) } returns expectedMessageBody + } + + private fun expectedDraftSaving(expectedMessageBody: MessageWithBody, successful: Boolean) { + coEvery { saveDraft(expectedMessageBody, userId) } returns successful + } + + private fun expectAttachmentSavingSuccessful(expectedMessageId: MessageId) { + coEvery { + attachmentRepository.saveAttachment(userId, expectedMessageId, localAttachmentId, uri) + } returns Unit.right() + } + + private fun expectAttachmentSavingFailed(expectedMessageId: MessageId, uri: Uri) { + coEvery { + attachmentRepository.saveAttachment(userId, expectedMessageId, localAttachmentId, uri) + } returns DataError.Local.FailedToStoreFile.left() + } + + private fun expectAttachmentStateSavingSuccess(messageId: MessageId) { + coEvery { + attachmentStateRepository.createOrUpdateLocalState(userId, messageId, localAttachmentId) + } returns Unit.right() + } + + private fun expectGetFileSizeFromUriSuccess(expectedUri: Uri = uri) { + coEvery { attachmentRepository.getFileSizeFromUri(expectedUri) } returns 1L.right() + } + + private fun expectGetFileSizeFromUriFailed() { + coEvery { attachmentRepository.getFileSizeFromUri(uri) } returns DataError.Local.NoDataCached.left() + } + + private fun expectGetFileSizeExceedsLimit() { + coEvery { attachmentRepository.getFileSizeFromUri(uri) } returns (30 * 1000 * 1000L).right() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFieldsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFieldsTest.kt new file mode 100644 index 0000000000..1d4fea9333 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithAllFieldsTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertTrue + +class StoreDraftWithAllFieldsTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val draftStateRepository = mockk() + private val prepareAndEncryptDraftBody = mockk() + private val saveDraft = mockk() + private val fakeTransactor = FakeTransactor() + + private val storeDraftWithAllFields = StoreDraftWithAllFields( + draftStateRepository, + prepareAndEncryptDraftBody, + saveDraft, + fakeTransactor + ) + + @Test + fun `saves draft with all fields`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val subject = Subject("Subject of this email") + val plaintextDraftBody = DraftBody("I am plaintext") + val quotedHtmlBody = OriginalHtmlQuote("
Input quoted html body
") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.Doe)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.Bob)) + val expectedAction = DraftAction.Compose + val draftFields = DraftFields( + senderEmail, + subject, + plaintextDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + quotedHtmlBody + ) + val draftBody = expectDraftBodyWithText(userId, draftMessageId, plaintextDraftBody, quotedHtmlBody, senderEmail) + val expectedDraftUpdated = draftBody.copy( + message = draftBody.message.copy( + subject = draftFields.subject.value, + toList = draftFields.recipientsTo.value, + ccList = draftFields.recipientsCc.value, + bccList = draftFields.recipientsBcc.value + ) + ) + expectStoreDraftStateSucceeds(userId, draftMessageId) + expectSaveDraftSuccess(userId, expectedDraftUpdated) + + // When + val result = storeDraftWithAllFields(userId, draftMessageId, draftFields) + + // Then + assertTrue(result.isRight()) + coVerifySequence { + prepareAndEncryptDraftBody(userId, draftMessageId, plaintextDraftBody, quotedHtmlBody, senderEmail) + saveDraft(expectedDraftUpdated, userId) + draftStateRepository.createOrUpdateLocalState(userId, draftMessageId, expectedAction) + } + } + + @Test + fun `should error when prepare draft body fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val subject = Subject("Subject of this email") + val plaintextDraftBody = DraftBody("I am plaintext") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.Doe)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.Bob)) + val draftFields = buildDraftFields( + senderEmail, + subject, + plaintextDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc + ) + expectDraftBodyError(userId, draftMessageId, plaintextDraftBody, null, senderEmail) + + // When + val result = storeDraftWithAllFields(userId, draftMessageId, draftFields) + + // Then + assertTrue(result.isLeft()) + + coVerifySequence { + prepareAndEncryptDraftBody(userId, draftMessageId, plaintextDraftBody, null, senderEmail) + saveDraft wasNot called + draftStateRepository wasNot called + } + } + + private fun buildDraftFields( + senderEmail: SenderEmail, + subject: Subject, + plaintextDraftBody: DraftBody, + recipientsTo: RecipientsTo = RecipientsTo(emptyList()), + recipientsCc: RecipientsCc = RecipientsCc(emptyList()), + recipientsBcc: RecipientsBcc = RecipientsBcc(emptyList()) + ) = DraftFields( + senderEmail, + subject, + plaintextDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + + private fun expectStoreDraftStateSucceeds( + userId: UserId, + draftMessageId: MessageId, + expectedAction: DraftAction = DraftAction.Compose + ) { + coEvery { + draftStateRepository.createOrUpdateLocalState(userId, draftMessageId, expectedAction) + } returns Unit.right() + } + + private fun expectDraftBodyWithText( + userId: UserId, + messageId: MessageId, + draftBody: DraftBody, + originalHtmlQuote: OriginalHtmlQuote?, + senderEmail: SenderEmail + ) = MessageWithBodySample.EmptyDraft.also { + coEvery { prepareAndEncryptDraftBody(userId, messageId, draftBody, originalHtmlQuote, senderEmail) } returns + it.right() + } + + private fun expectDraftBodyError( + userId: UserId, + messageId: MessageId, + draftBody: DraftBody, + originalHtmlQuote: OriginalHtmlQuote?, + senderEmail: SenderEmail + ) { + coEvery { prepareAndEncryptDraftBody(userId, messageId, draftBody, originalHtmlQuote, senderEmail) } returns + PrepareDraftBodyError.DraftReadError.left() + } + + private fun expectSaveDraftSuccess(userId: UserId, messageWithBody: MessageWithBody) { + coEvery { saveDraft(messageWithBody, userId) } returns true + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBodyTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBodyTest.kt new file mode 100644 index 0000000000..0e5a62886e --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithBodyTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class StoreDraftWithBodyTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val saveDraftMock = mockk() + private val prepareAndEncryptDraftBody = mockk() + private val fakeTransactor = FakeTransactor() + + private val storeDraftWithBody = StoreDraftWithBody( + prepareAndEncryptDraftBody, + saveDraftMock, + fakeTransactor + ) + + @Test + fun `should save a draft with the proper message and user Id`() = runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val expectedUserId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val expectedDraft = expectedMessageWithBody(MessageWithBodySample.NewDraftWithSubject) + + givenSaveDraftSucceeds(expectedDraft, expectedUserId) + + // When + val actualEither = storeDraftWithBody( + expectedUserId, draftMessageId, plaintextDraftBody, NoQuotedHtmlBody, senderEmail + ) + + // Then + coVerifySequence { + prepareAndEncryptDraftBody( + expectedUserId, + draftMessageId, + plaintextDraftBody, + NoQuotedHtmlBody, + senderEmail + ) + saveDraftMock(expectedDraft, expectedUserId) + } + assertEquals(Unit.right(), actualEither) + } + + @Test + fun `should return error when saving fails`() = runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val expectedUserId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val expectedDraft = expectedMessageWithBody(MessageWithBodySample.NewDraftWithSubject) + + givenSaveDraftFails(expectedDraft, expectedUserId) + + // When + val actualEither = storeDraftWithBody( + expectedUserId, draftMessageId, plaintextDraftBody, NoQuotedHtmlBody, senderEmail + ) + + // Then + assertEquals(StoreDraftWithBodyError.DraftSaveError.left(), actualEither) + coVerifySequence { + prepareAndEncryptDraftBody( + expectedUserId, + draftMessageId, + plaintextDraftBody, + NoQuotedHtmlBody, + senderEmail + ) + saveDraftMock(expectedDraft, expectedUserId) + } + loggingTestRule.assertErrorLogged("Store draft $draftMessageId body to local DB failed") + } + + @Test + fun `should return error when preparing the draft body fails`() = runTest { + // Given + val plaintextDraftBody = DraftBody("I am plaintext") + val senderAddress = UserAddressSample.build() + val senderEmail = SenderEmail(senderAddress.email) + val expectedUserId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + + expectBodyPreparationFailure() + + // When + val actualEither = storeDraftWithBody( + expectedUserId, draftMessageId, plaintextDraftBody, NoQuotedHtmlBody, senderEmail + ) + + // Then + assertEquals(StoreDraftWithBodyError.DraftSaveError.left(), actualEither) + coVerifySequence { + prepareAndEncryptDraftBody( + expectedUserId, + draftMessageId, + plaintextDraftBody, + NoQuotedHtmlBody, + senderEmail + ) + saveDraftMock wasNot called + } + loggingTestRule.assertErrorLogged("Prepare encrypted $draftMessageId body failed") + } + + private fun expectedMessageWithBody(localDraft: MessageWithBody): MessageWithBody { + coEvery { + prepareAndEncryptDraftBody(any(), any(), any(), any(), any()) + } returns localDraft.right() + + return localDraft + } + + private fun expectBodyPreparationFailure() { + coEvery { + prepareAndEncryptDraftBody(any(), any(), any(), any(), any()) + } returns PrepareDraftBodyError.DraftBodyEncryptionError.left() + } + + private fun givenSaveDraftSucceeds(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns true + } + + private fun givenSaveDraftFails(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns false + } + + companion object { + + private val NoQuotedHtmlBody = null + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachmentsTest.kt new file mode 100644 index 0000000000..de63676164 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithParentAttachmentsTest.kt @@ -0,0 +1,520 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.repository.AttachmentRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import io.mockk.Called +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.coVerifySequence +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreDraftWithParentAttachmentsTest { + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val userId = UserIdSample.Primary + private val draftMessageId = MessageIdSample.build() + private val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + + private val attachmentRepository = mockk() + private val deleteAllAttachments = mockk() + private val saveDraftMock = mockk() + private val getLocalDraftMock = mockk() + private val storeParentAttachmentStates = mockk() + private val fakeTransactor = FakeTransactor() + + private val storeDraftWithParentAttachments = StoreDraftWithParentAttachments( + attachmentRepository = attachmentRepository, + deleteAllAttachments = deleteAllAttachments, + getLocalDraft = getLocalDraftMock, + saveDraft = saveDraftMock, + storeParentAttachmentStates = storeParentAttachmentStates, + transactor = fakeTransactor + ) + + @Test + fun `store draft with all attachments when action is forward`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.MessageWithAttachments, + DecryptedMessageBodyTestData.MessageWithAttachments + ) + val expectedAction = DraftAction.Forward(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy( + attachments = expectedParentMessage.decryptedMessageBody.attachments + ) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenStoreParentAttachmentsSucceeds( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachments = expectedSavedDraft.messageBody.attachments.map { it.attachmentId }, + syncState = AttachmentSyncState.External + ) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifyOrder { + saveDraftMock(expectedSavedDraft, userId) + storeParentAttachmentStates( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachmentIds = expectedSavedDraft.messageBody.attachments.map { it.attachmentId }, + syncState = AttachmentSyncState.External + ) + } + assertEquals(Unit.right(), result) + } + + @Test + fun `store draft with inline attachments only when action is reply or reply all`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.MessageWithAttachments, + DecryptedMessageBodyTestData.MessageWithAttachments + ) + val expectedAction = DraftAction.Reply(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments.filter { + it.disposition == "inline" + } + val expectedAttachmentIds = expectedAttachments.map { it.attachmentId } + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenStoreParentAttachmentsSucceeds( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachments = expectedAttachmentIds, + syncState = AttachmentSyncState.External + ) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifyOrder { + saveDraftMock(expectedSavedDraft, userId) + storeParentAttachmentStates( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachmentIds = expectedAttachmentIds, + syncState = AttachmentSyncState.External + ) + } + assertEquals(Unit.right(), result) + } + + @Test + fun `returns no attachment to be stored when parent has no attachments to store for the given action`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.Invoice, + DecryptedMessageBodyTestData.buildDecryptedMessageBody(messageId = MessageIdSample.Invoice) + ) + val expectedAction = DraftAction.ReplyAll(expectedParentMessage.messageWithBody.message.messageId) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerify { saveDraftMock wasNot Called } + assertEquals(StoreDraftWithParentAttachments.Error.NoAttachmentsToBeStored.left(), result) + } + + @Test + fun `returns action with no parent error when called with Compose action`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.Invoice, + DecryptedMessageBodyTestData.buildDecryptedMessageBody(messageId = MessageIdSample.Invoice) + ) + val expectedAction = DraftAction.Compose + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerify { saveDraftMock wasNot Called } + assertEquals(StoreDraftWithParentAttachments.Error.ActionWithNoParent.left(), result) + } + + @Test + fun `returns draft data error when failing to store the draft`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.MessageWithAttachments, + DecryptedMessageBodyTestData.MessageWithAttachments + ) + val expectedAction = DraftAction.ReplyAll(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments.filter { + it.disposition == "inline" + } + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftFails(expectedSavedDraft, userId) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerify { saveDraftMock(expectedSavedDraft, userId) } + assertEquals(StoreDraftWithParentAttachments.Error.DraftDataError.left(), result) + } + + @Test + fun `removes signature and encrypted signature from parent attachments before storing them`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.MessageWithSignedAttachments, + DecryptedMessageBodyTestData.MessageWithSignedAttachments + ) + val expectedAction = DraftAction.Forward(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments.map { + it.copy(signature = null, encSignature = null) + } + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenStoreParentAttachmentsSucceeds( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachments = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.External + ) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifySequence { + saveDraftMock(expectedSavedDraft, userId) + storeParentAttachmentStates( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachmentIds = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.External + ) + } + assertEquals(Unit.right(), result) + } + + @Test + fun `should copy embedded attachments from parent pgp mime message and set their status to local when replying`() = + runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.PgpMimeMessage, + DecryptedMessageBodyTestData.PgpMimeMessage + ) + val expectedAction = DraftAction.Reply(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments.filter { + it.disposition == "inline" + } + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenCopyAttachmentsFromParentMessageSucceeds( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + givenStoreParentAttachmentsSucceeds( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachments = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.Local + ) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifySequence { + saveDraftMock(expectedSavedDraft, userId) + attachmentRepository.copyMimeAttachmentsToMessage( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + storeParentAttachmentStates( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachmentIds = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.Local + ) + } + assertEquals(Unit.right(), result) + } + + @Test + fun `should copy all attachments from parent pgp mime message and set their status to local when forwarding`() = + runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.PgpMimeMessage, + DecryptedMessageBodyTestData.PgpMimeMessage + ) + val expectedAction = DraftAction.Forward(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenCopyAttachmentsFromParentMessageSucceeds( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + givenStoreParentAttachmentsSucceeds( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachments = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.Local + ) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifySequence { + saveDraftMock(expectedSavedDraft, userId) + attachmentRepository.copyMimeAttachmentsToMessage( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + storeParentAttachmentStates( + userId = userId, + messageId = expectedSavedDraft.message.messageId, + attachmentIds = expectedAttachments.map { it.attachmentId }, + syncState = AttachmentSyncState.Local + ) + } + assertEquals(Unit.right(), result) + } + + @Test + fun `should delete all parent attachments when copying them from parent pgp mime message fails`() = runTest { + // Given + val expectedParentMessage = MessageWithDecryptedBody( + MessageWithBodySample.PgpMimeMessage, + DecryptedMessageBodyTestData.PgpMimeMessage + ) + val expectedAction = DraftAction.Forward(expectedParentMessage.messageWithBody.message.messageId) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedAttachments = expectedParentMessage.decryptedMessageBody.attachments + val expectedSavedDraft = draftWithBody.copy( + messageBody = draftWithBody.messageBody.copy(attachments = expectedAttachments) + ) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + givenCopyAttachmentsFromParentMessageFails( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + givenDeleteAllAttachmentsSucceeds(userId, senderEmail, draftMessageId) + + // When + val result = storeDraftWithParentAttachments( + userId, + draftMessageId, + expectedParentMessage, + senderEmail, + expectedAction + ) + + // Then + coVerifySequence { + saveDraftMock(expectedSavedDraft, userId) + attachmentRepository.copyMimeAttachmentsToMessage( + userId = userId, + sourceMessageId = expectedParentMessage.messageWithBody.message.messageId, + targetMessageId = draftMessageId, + attachmentIds = expectedAttachments.map { it.attachmentId } + ) + deleteAllAttachments(userId, senderEmail, draftMessageId) + } + assertEquals(StoreDraftWithParentAttachments.Error.DraftAttachmentError.left(), result) + } + + private fun expectedGetLocalDraft( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + localDraft: () -> MessageWithBody + ): MessageWithBody = localDraft().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.right() + } + + private fun givenSaveDraftSucceeds(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns true + } + + private fun givenSaveDraftFails(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns false + } + + private fun givenCopyAttachmentsFromParentMessageSucceeds( + userId: UserId, + sourceMessageId: MessageId, + targetMessageId: MessageId, + attachmentIds: List + ) { + coEvery { + attachmentRepository.copyMimeAttachmentsToMessage(userId, sourceMessageId, targetMessageId, attachmentIds) + } returns Unit.right() + } + + private fun givenCopyAttachmentsFromParentMessageFails( + userId: UserId, + sourceMessageId: MessageId, + targetMessageId: MessageId, + attachmentIds: List + ) { + coEvery { + attachmentRepository.copyMimeAttachmentsToMessage(userId, sourceMessageId, targetMessageId, attachmentIds) + } returns DataError.Local.FailedToStoreFile.left() + } + + private fun givenDeleteAllAttachmentsSucceeds( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId + ) { + coEvery { deleteAllAttachments(userId, senderEmail, messageId) } just Runs + } + + private fun givenStoreParentAttachmentsSucceeds( + userId: UserId, + messageId: MessageId, + attachments: List, + syncState: AttachmentSyncState + ) { + coEvery { + storeParentAttachmentStates(userId, messageId, attachments, syncState) + } returns Unit.right() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipientsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipientsTest.kt new file mode 100644 index 0000000000..d5f58304cd --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithRecipientsTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample +import ch.protonmail.android.test.utils.FakeTransactor +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals + +class StoreDraftWithRecipientsTest { + + private val saveDraftMock = mockk() + private val getLocalDraftMock = mockk() + private val fakeTransactor = FakeTransactor() + + private val storeDraftWithRecipients = StoreDraftWithRecipients( + getLocalDraftMock, + saveDraftMock, + fakeTransactor + ) + + @Test + fun `save draft with recipients TO`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val to = listOf(RecipientSample.John, RecipientSample.Doe) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(toList = to)) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithRecipients(userId, draftMessageId, senderEmail, to = to) + + // Then + coVerify { saveDraftMock(expectedSavedDraft, userId) } + assertEquals(Unit.right(), actualEither) + } + + @Test + fun `save draft with recipients CC`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val cc = listOf(RecipientSample.John, RecipientSample.Doe) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(ccList = cc)) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithRecipients(userId, draftMessageId, senderEmail, cc = cc) + + // Then + coVerify { saveDraftMock(expectedSavedDraft, userId) } + assertEquals(Unit.right(), actualEither) + } + + @Test + fun `save draft with recipients BCC`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val bcc = listOf(RecipientSample.John, RecipientSample.Doe) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(bccList = bcc)) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithRecipients(userId, draftMessageId, senderEmail, bcc = bcc) + + // Then + coVerify { saveDraftMock(expectedSavedDraft, userId) } + assertEquals(Unit.right(), actualEither) + } + + + @Test + fun `returns error when get local draft fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val to = listOf(RecipientSample.John, RecipientSample.Doe) + expectedGetLocalDraftFails(userId, draftMessageId, senderEmail) { + GetLocalDraft.Error.ResolveUserAddressError + } + + // When + val actualEither = storeDraftWithRecipients(userId, draftMessageId, senderEmail, to = to) + + // Then + coVerify { saveDraftMock wasNot Called } + assertEquals(StoreDraftWithRecipients.Error.DraftReadError.left(), actualEither) + } + + @Test + fun `returns error when save draft fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val to = listOf(RecipientSample.John, RecipientSample.Doe) + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(toList = to)) + givenSaveDraftFails(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithRecipients(userId, draftMessageId, senderEmail, to = to) + + // Then + assertEquals(StoreDraftWithRecipients.Error.DraftSaveError.left(), actualEither) + } + + private fun expectedGetLocalDraft( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + localDraft: () -> MessageWithBody + ): MessageWithBody = localDraft().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.right() + } + + private fun expectedGetLocalDraftFails( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + error: () -> GetLocalDraft.Error + ): GetLocalDraft.Error = error().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.left() + } + + private fun givenSaveDraftSucceeds(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns true + } + + private fun givenSaveDraftFails(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns false + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubjectTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubjectTest.kt new file mode 100644 index 0000000000..5e7f7e91f1 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreDraftWithSubjectTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.test.utils.FakeTransactor +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.assertEquals + +class StoreDraftWithSubjectTest { + + private val saveDraftMock = mockk() + private val getLocalDraftMock = mockk() + private val fakeTransactor = FakeTransactor() + + private val storeDraftWithSubject = StoreDraftWithSubject( + getLocalDraftMock, + saveDraftMock, + fakeTransactor + ) + + @Test + fun `save draft with subject`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val subject = Subject("Subject of this email") + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(subject = subject.value)) + givenSaveDraftSucceeds(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithSubject(userId, draftMessageId, senderEmail, subject) + + // Then + coVerify { saveDraftMock(expectedSavedDraft, userId) } + assertEquals(Unit.right(), actualEither) + } + + @Test + fun `returns error when get local draft fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val subject = Subject("Subject of this email") + expectedGetLocalDraftFails(userId, draftMessageId, senderEmail) { + GetLocalDraft.Error.ResolveUserAddressError + } + + // When + val actualEither = storeDraftWithSubject(userId, draftMessageId, senderEmail, subject) + + // Then + coVerify { saveDraftMock wasNot Called } + assertEquals(StoreDraftWithSubject.Error.DraftReadError.left(), actualEither) + } + + @Test + fun `returns error when save draft fails`() = runTest { + // Given + val userId = UserIdSample.Primary + val draftMessageId = MessageIdSample.build() + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val subject = Subject("Subject of this email") + val draftWithBody = expectedGetLocalDraft(userId, draftMessageId, senderEmail) { + MessageWithBodySample.EmptyDraft + } + val expectedSavedDraft = draftWithBody.copy(message = draftWithBody.message.copy(subject = subject.value)) + givenSaveDraftFails(expectedSavedDraft, userId) + + // When + val actualEither = storeDraftWithSubject(userId, draftMessageId, senderEmail, subject) + + // Then + assertEquals(StoreDraftWithSubject.Error.DraftSaveError.left(), actualEither) + } + + private fun expectedGetLocalDraft( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + localDraft: () -> MessageWithBody + ): MessageWithBody = localDraft().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.right() + } + + private fun expectedGetLocalDraftFails( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + error: () -> GetLocalDraft.Error + ): GetLocalDraft.Error = error().also { + coEvery { getLocalDraftMock.invoke(userId, messageId, senderEmail) } returns it.left() + } + + private fun givenSaveDraftSucceeds(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns true + } + + private fun givenSaveDraftFails(messageWithBody: MessageWithBody, userId: UserId) { + coEvery { saveDraftMock(messageWithBody, userId) } returns false + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachmentsTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachmentsTest.kt new file mode 100644 index 0000000000..9db30da35b --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreExternalAttachmentsTest.kt @@ -0,0 +1,81 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailcomposer.domain.sample.AttachmentStateSample +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class StoreExternalAttachmentsTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.Invoice + private val attachmentIds = listOf(AttachmentId("1"), AttachmentId("2")) + private val attachmentStateRepository = mockk() + private val messageRepository = mockk() + + private val storeExternalAttachments by lazy { + StoreExternalAttachments(messageRepository, attachmentStateRepository) + } + + @Test + fun `when store parent attachment is called without attachments then only the not yet stored states are stored`() = + runTest { + // Given + expectGetMessageWithBodySucceeds() + expectGetAllAttachmentStatesForMessageSucceeds() + val expectedAttachmentList = listOf(AttachmentId("embeddedImageId")) + expectCreateOrUpdateStatesSucceeds(expectedAttachmentList, AttachmentSyncState.ExternalUploaded) + + // When + storeExternalAttachments(userId, messageId) + + // Then + coVerify { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + expectedAttachmentList, + AttachmentSyncState.ExternalUploaded + ) + } + } + + private fun expectGetMessageWithBodySucceeds() { + coEvery { + messageRepository.getMessageWithBody(userId, messageId) + } returns MessageWithBodySample.MessageWithAttachments.right() + } + + private fun expectGetAllAttachmentStatesForMessageSucceeds() { + coEvery { + attachmentStateRepository.getAllAttachmentStatesForMessage(userId, messageId) + } returns listOf( + AttachmentStateSample.build(userId, messageId, AttachmentId("document")) + ) + } + + private fun expectCreateOrUpdateStatesSucceeds( + ids: List = attachmentIds, + expectedSyncState: AttachmentSyncState + ) { + coEvery { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + ids, + expectedSyncState + ) + } returns Unit.right() + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStatesTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStatesTest.kt new file mode 100644 index 0000000000..e5cde6aa87 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/StoreParentAttachmentStatesTest.kt @@ -0,0 +1,74 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreParentAttachmentStatesTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.Invoice + private val attachmentIds = listOf(AttachmentId("1"), AttachmentId("2")) + private val attachmentStateRepository = mockk() + + private val storeParentAttachmentStates = StoreParentAttachmentStates(attachmentStateRepository) + + @Test + fun `when store parent attachments is called then the attachment state repository is called`() = runTest { + // Given + expectCreateOrUpdateStatesSucceeds(expectedSyncState = AttachmentSyncState.External) + + // When + val actual = storeParentAttachmentStates(userId, messageId, attachmentIds, AttachmentSyncState.External) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `when store parent attachment fails then data error is returned`() = runTest { + // Given + expectCreateOrUpdateStatesFails() + + // When + val actual = storeParentAttachmentStates(userId, messageId, attachmentIds, AttachmentSyncState.External) + + // Then + assertEquals(DataError.Local.Unknown.left(), actual) + } + + private fun expectCreateOrUpdateStatesSucceeds( + ids: List = attachmentIds, + expectedSyncState: AttachmentSyncState + ) { + coEvery { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + ids, + expectedSyncState + ) + } returns Unit.right() + } + + private fun expectCreateOrUpdateStatesFails() { + coEvery { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + attachmentIds, + AttachmentSyncState.External + ) + } returns DataError.Local.Unknown.left() + } +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForErrorTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForErrorTest.kt new file mode 100644 index 0000000000..5c91cfc1c2 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateDraftStateForErrorTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.MessageRepository +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.DraftState +import ch.protonmail.android.mailmessage.domain.model.DraftSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.repository.DraftStateRepository +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test + +class UpdateDraftStateForErrorTest { + + private val draftStateRepository: DraftStateRepository = mockk() + private val messageRepository: MessageRepository = mockk() + + private val updateDraftStateForError = UpdateDraftStateForError( + draftStateRepository, + messageRepository + ) + + @Test + fun `update draft state to the given one when current state is not Sending`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadDraft + val sendingError = SendingError.Other + givenExistingDraftState(userId, messageId) { buildDraftState(DraftSyncState.Local) } + givenUpdateDraftSyncStateSucceeds(userId, messageId, newState) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + coVerify { draftStateRepository.updateDraftSyncState(userId, messageId, newState) } + coVerify(exactly = 0) { draftStateRepository.updateSendingError(userId, messageId, any()) } + } + + @Test + fun `does not move message back to draft folder when current state is not Sending`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadDraft + val sendingError = SendingError.Other + givenExistingDraftState(userId, messageId) { buildDraftState(DraftSyncState.Local) } + givenUpdateDraftSyncStateSucceeds(userId, messageId, newState) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + verify { messageRepository wasNot Called } + coVerify(exactly = 0) { draftStateRepository.updateSendingError(userId, messageId, any()) } + } + + @Test + fun `update draft state to Error Sending when current state is Sending`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadAttachments + val expectedState = DraftSyncState.ErrorSending + val sendingError = SendingError.Other + givenExistingDraftState(userId, messageId) { buildDraftState(DraftSyncState.Sending) } + givenUpdateDraftSyncStateSucceeds(userId, messageId, expectedState) + givenUpdateSendingErrorStateSucceeds(userId, messageId, sendingError) + givenMoveMessageBackFromSentToDraftsSucceeds(userId, messageId) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + coVerify { draftStateRepository.updateDraftSyncState(userId, messageId, expectedState) } + coVerify { draftStateRepository.updateSendingError(userId, messageId, sendingError) } + } + + @Test + fun `moves message back to draft folder when current state is Sending`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadDraft + val expectedState = DraftSyncState.ErrorSending + val sendingError = SendingError.Other + givenExistingDraftState(userId, messageId) { buildDraftState(DraftSyncState.Sending) } + givenUpdateDraftSyncStateSucceeds(userId, messageId, expectedState) + givenUpdateSendingErrorStateSucceeds(userId, messageId, sendingError) + givenMoveMessageBackFromSentToDraftsSucceeds(userId, messageId) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + coVerify { messageRepository.moveMessageBackFromSentToDrafts(userId, messageId) } + } + + + @Test + fun `moves message back to draft folder using api message if available when current state is Sending`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadDraft + val expectedState = DraftSyncState.ErrorSending + val sendingError = SendingError.Other + val apiMessageId = MessageIdSample.RemoteDraft + givenExistingDraftState(userId, messageId) { + buildDraftStateWithApiMessageId(DraftSyncState.Sending, apiMessageId) + } + givenUpdateDraftSyncStateSucceeds(userId, messageId, expectedState) + givenUpdateSendingErrorStateSucceeds(userId, messageId, sendingError) + givenMoveMessageBackFromSentToDraftsSucceeds(userId, apiMessageId) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + coVerify { messageRepository.moveMessageBackFromSentToDrafts(userId, apiMessageId) } + } + + @Test + fun `update draft state to Sent when SendingError is MessageAlreadySent`() = runTest { + // Given + val userId = UserIdSample.Primary + val messageId = MessageIdSample.Invoice + val newState = DraftSyncState.ErrorUploadDraft + val expectedState = DraftSyncState.Sent + val sendingError = SendingError.MessageAlreadySent + givenExistingDraftState(userId, messageId) { buildDraftState(DraftSyncState.Sending) } + givenUpdateDraftSyncStateSucceeds(userId, messageId, expectedState) + givenUpdateSendingErrorStateSucceeds(userId, messageId, sendingError) + givenMoveMessageBackFromSentToDraftsSucceeds(userId, messageId) + + // When + updateDraftStateForError(userId, messageId, newState, sendingError) + + // Then + coVerify { draftStateRepository.updateDraftSyncState(userId, messageId, expectedState) } + } + + private fun buildDraftState(syncState: DraftSyncState) = DraftState( + userId = UserIdSample.Primary, + messageId = MessageIdSample.LocalDraft, + apiMessageId = null, + state = syncState, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ) + + private fun buildDraftStateWithApiMessageId(syncState: DraftSyncState, apiMessageId: MessageId) = DraftState( + userId = UserIdSample.Primary, + messageId = MessageIdSample.LocalDraft, + apiMessageId = apiMessageId, + state = syncState, + action = DraftAction.Compose, + sendingError = null, + sendingStatusConfirmed = false + ) + + private fun givenExistingDraftState( + userId: UserId, + messageId: MessageId, + expected: () -> DraftState + ) = expected().also { + coEvery { draftStateRepository.observe(userId, messageId) } returns flowOf(it.right()) + } + + private fun givenUpdateDraftSyncStateSucceeds( + userId: UserId, + messageId: MessageId, + state: DraftSyncState + ) { + coEvery { draftStateRepository.updateDraftSyncState(userId, messageId, state) } returns Unit.right() + } + + private fun givenUpdateSendingErrorStateSucceeds( + userId: UserId, + messageId: MessageId, + sendingError: SendingError + ) { + coEvery { draftStateRepository.updateSendingError(userId, messageId, sendingError) } returns Unit.right() + } + + private fun givenMoveMessageBackFromSentToDraftsSucceeds(userId: UserId, messageId: MessageId) { + coJustRun { messageRepository.moveMessageBackFromSentToDrafts(userId, messageId) } + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateParentAttachmentStatesTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateParentAttachmentStatesTest.kt new file mode 100644 index 0000000000..464d0413b1 --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/UpdateParentAttachmentStatesTest.kt @@ -0,0 +1,100 @@ +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.repository.AttachmentStateRepository +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentState +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class UpdateParentAttachmentStatesTest { + + private val userId = UserIdSample.Primary + private val messageId = MessageIdSample.Invoice + private val attachmentIds = listOf(AttachmentId("1"), AttachmentId("2")) + private val attachmentStateRepository = mockk() + + private val createOrUpdateParentAttachmentStates = CreateOrUpdateParentAttachmentStates(attachmentStateRepository) + + @Test + fun `when update parent attachment states for an existing and a new attachment then the repository is called`() = + runTest { + // Given + expectAttachmentStateIsParent(attachmentIds[0]) + expectAttachmentStateIsNull(attachmentIds[1]) + expectCreateOrUpdateStatesSucceeds() + + // When + createOrUpdateParentAttachmentStates(userId, messageId, attachmentIds) + + // Then + coVerify { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + attachmentIds, + AttachmentSyncState.ExternalUploaded + ) + } + } + + @Test + fun `when update parent attachment states with a local attachment is called then the local is filtered`() = + runTest { + // Given + expectAttachmentStateIsParent(attachmentIds[0]) + expectAttachmentStateIsLocal(attachmentIds[1]) + expectCreateOrUpdateStatesSucceeds(listOf(attachmentIds[0])) + + // When + createOrUpdateParentAttachmentStates(userId, messageId, attachmentIds) + + // Then + coVerify { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + listOf(attachmentIds[0]), + AttachmentSyncState.ExternalUploaded + ) + } + } + + private fun expectAttachmentStateIsParent(attachmentId: AttachmentId) { + coEvery { + attachmentStateRepository.getAttachmentState(userId, messageId, attachmentId) + } returns AttachmentState(userId, messageId, attachmentId, AttachmentSyncState.External).right() + } + + private fun expectAttachmentStateIsNull(attachmentId: AttachmentId) { + coEvery { + attachmentStateRepository.getAttachmentState(userId, messageId, attachmentId) + } returns DataError.Local.NoDataCached.left() + } + + private fun expectAttachmentStateIsLocal(attachmentId: AttachmentId) { + coEvery { + attachmentStateRepository.getAttachmentState(userId, messageId, attachmentId) + } returns AttachmentState(userId, messageId, attachmentId, AttachmentSyncState.Local).right() + } + + private fun expectCreateOrUpdateStatesSucceeds(ids: List = attachmentIds) { + coEvery { + attachmentStateRepository.createOrUpdateLocalStates( + userId, + messageId, + ids, + AttachmentSyncState.ExternalUploaded + ) + } returns Unit.right() + } + +} diff --git a/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddressTest.kt b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddressTest.kt new file mode 100644 index 0000000000..a8889d269f --- /dev/null +++ b/mail-composer/domain/src/test/kotlin/ch/protonmail/android/mailcomposer/domain/usecase/ValidateSenderAddressTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.domain.usecase + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.IsPaidUser +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import org.junit.Test +import kotlin.test.assertEquals + +class ValidateSenderAddressTest { + + private val observeUserAddresses = mockk() + private val isPaidUser = mockk() + + private val validateSenderAddress = ValidateSenderAddress(observeUserAddresses, isPaidUser) + + @Test + fun `returns could not validate error when failing to get user addresses`() = runTest { + // Given + val senderEmail = SenderEmail("any@email.me") + expectUserAddressesError(userId) + + // When + val actual = validateSenderAddress(userId, senderEmail) + + // Then + assertEquals(ValidateSenderAddress.ValidationFailure.CouldNotValidate.left(), actual) + } + + @Test + fun `returns could not validate error when failing to get given user address`() = runTest { + // Given + val senderEmail = SenderEmail("not-found@email.me") + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + expectPaidUser(userId) + + // When + val actual = validateSenderAddress(userId, senderEmail) + + // Then + assertEquals(ValidateSenderAddress.ValidationFailure.CouldNotValidate.left(), actual) + } + + @Test + fun `returns no enabled address error when none of the user address is enabled`() = runTest { + // Given + val senderEmail = SenderEmail("disabled@protonmail.ch") + expectedUserAddresses(userId) { listOf(UserAddressSample.DisabledAddress) } + expectPaidUser(userId) + + // When + val actual = validateSenderAddress(userId, senderEmail) + + // Then + assertEquals(ValidateSenderAddress.ValidationFailure.AllAddressesDisabled.left(), actual) + } + + @Test + fun `returns invalid result when given address is disabled and another valid address exists`() = runTest { + // Given + val disabledSenderEmail = SenderEmail("disabled@protonmail.ch") + val validAddress = UserAddressSample.PrimaryAddress + expectedUserAddresses(userId) { listOf(UserAddressSample.DisabledAddress, validAddress) } + expectPaidUser(userId) + + // When + val actual = validateSenderAddress(userId, disabledSenderEmail) + + // Then + val expected = ValidateSenderAddress.ValidationResult.Invalid( + SenderEmail(validAddress.email), + disabledSenderEmail, + ValidateSenderAddress.ValidationError.DisabledAddress + ).right() + assertEquals(expected, actual) + } + + @Test + fun `returns invalid result when user is free and given address is @pm me`() = runTest { + // Given + val pmMeEmail = SenderEmail("myaddress@pm.me") + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress, UserAddressSample.PmMeAddressAlias) } + expectFreeUser(userId) + + // When + val actual = validateSenderAddress(userId, pmMeEmail) + + // Then + val expected = ValidateSenderAddress.ValidationResult.Invalid( + SenderEmail(UserAddressSample.PrimaryAddress.email), + pmMeEmail, + ValidateSenderAddress.ValidationError.PaidAddress + ).right() + assertEquals(expected, actual) + } + + @Test + fun `returns valid result when the given address is valid for sending`() = runTest { + // Given + val aliasEmail = SenderEmail("alias@protonmail.ch") + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) } + expectFreeUser(userId) + + // When + val actual = validateSenderAddress(userId, aliasEmail) + + // Then + val expected = ValidateSenderAddress.ValidationResult.Valid(aliasEmail).right() + assertEquals(expected, actual) + } + + private fun expectPaidUser(userId: UserId) { + coEvery { isPaidUser(userId) } returns true.right() + } + + private fun expectFreeUser(userId: UserId) { + coEvery { isPaidUser(userId) } returns false.right() + } + + private fun expectedUserAddresses(userId: UserId, addresses: () -> List) = addresses().also { + every { observeUserAddresses.invoke(userId) } returns flowOf(it) + } + + private fun expectUserAddressesError(userId: UserId) { + every { observeUserAddresses.invoke(userId) } returns flowOf() + } + + companion object { + private val userId = UserIdSample.Primary + + } +} diff --git a/mail-composer/presentation/build.gradle.kts b/mail-composer/presentation/build.gradle.kts new file mode 100644 index 0000000000..ae139fe965 --- /dev/null +++ b/mail-composer/presentation/build.gradle.kts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("kapt") + kotlin("android") + kotlin("plugin.serialization") + id("dagger.hilt.android.plugin") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "ch.protonmail.android.mailcomposer.presentation" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + compose = true + } + + packaging { + resources.excludes.add("META-INF/licenses/**") + resources.excludes.add("META-INF/LICENSE*") + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.dagger.hilt.android) + + implementation(libs.bundles.module.presentation) + implementation(libs.accompanist.permissions) + + implementation(libs.proton.core.contact) + implementation(libs.proton.core.user) + + implementation(project(":mail-common:domain")) + implementation(project(":mail-common:presentation")) + implementation(project(":mail-composer:domain")) + implementation(project(":mail-contact:domain")) + implementation(project(":mail-message:domain")) + implementation(project(":mail-message:presentation")) + implementation(project(":mail-pagination:domain")) + implementation(project(":mail-settings:domain")) + implementation(project(":mail-settings:presentation")) + implementation(project(":test:idlingresources")) + implementation(project(":uicomponents")) + + debugImplementation(libs.bundles.compose.debug) + + testImplementation(libs.bundles.test) + testImplementation(libs.proton.core.label.domain) + testImplementation(project(":test:test-data")) + testImplementation(project(":test:utils")) + testImplementation(project(":mail-detail:presentation")) + androidTestImplementation(libs.bundles.test.androidTest) +} diff --git a/mail-composer/presentation/src/androidTest/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainTextTest.kt b/mail-composer/presentation/src/androidTest/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainTextTest.kt new file mode 100644 index 0000000000..253a4cffbd --- /dev/null +++ b/mail-composer/presentation/src/androidTest/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainTextTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ConvertHtmlToPlainTextTest { + + private val convertHtmlToPlainText = ConvertHtmlToPlainText() + + @Test + fun shouldParseHtmlIntoString() { + // Given + val expected = """ + A message + with body & new lines. + With some other characters at the end <> /\ + + """.trimIndent() + + val htmlMessage = """ +

A message
+ with body & new lines.

+

With some other characters at the end <> /\

+ """.trimIndent() + + // When + val actual = convertHtmlToPlainText(htmlMessage) + + // Then + assertEquals(expected, actual) + } + + @Test + fun shouldRemoveHeadFromHtml() { + // Given + val expected = """ + Sent to myself + """.trimIndent() + + // When + val actual = convertHtmlToPlainText(HTML_WITH_CSS_IN_HEAD) + + // Then + assertEquals(expected, actual) + } + + @Test + fun shouldRemoveStyleFromAnywhereInTheHtml() { + // Given + val expected = """ + I have no style + """.trimIndent() + + // When + val actual = convertHtmlToPlainText(HTML_WITH_HEAD_AND_STYLE_IN_BODY) + + // Then + assertEquals(expected, actual) + } + + @Test + fun shouldRemoveOpenStyleFromAnywhereInTheHtml() { + // Given + val expected = """ + I have no style + """.trimIndent() + + // When + val actual = convertHtmlToPlainText(HTML_WITH_HEAD_AND_OPEN_STYLE_IN_BODY) + + // Then + assertEquals(expected, actual) + } +} + +private val HTML_WITH_HEAD_AND_OPEN_STYLE_IN_BODY = """ + | + | + | + | + | + | + | + |I have no style + | +""".trimMargin("|") + +private val HTML_WITH_HEAD_AND_STYLE_IN_BODY = """ + | + | + | + | + | + | + | + |I have no style + | +""".trimMargin("|") + +private val HTML_WITH_CSS_IN_HEAD = """ + | + | + | + |Sent to myself +""".trimMargin("|") diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacade.kt new file mode 100644 index 0000000000..a1657d51a5 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacade.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationFailure +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationFailure.CouldNotValidate +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationResult +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class AddressesFacade @Inject constructor( + private val getPrimaryAddress: GetPrimaryAddress, + private val getComposerSenderAddresses: GetComposerSenderAddresses, + private val validateSenderAddress: ValidateSenderAddress +) { + + suspend fun getPrimarySenderEmail(userId: UserId): Either = either { + val address = getPrimaryAddress.invoke(userId).getOrElse { + raise(DataError.Local.NoDataCached) + } + + SenderEmail(address.email) + } + + suspend fun getSenderAddresses() = getComposerSenderAddresses.invoke() + + suspend fun validateSenderAddress( + userId: UserId, + senderEmail: SenderEmail + ): Either { + val validationResult = validateSenderAddress.invoke(userId, senderEmail).getOrNull() + if (validationResult != null) return validationResult.right() + + val fallbackAddress = getPrimaryAddress.invoke(userId) + .getOrNull() + ?.let { SenderEmail(it.email) } + + return if (fallbackAddress == null) { + CouldNotValidate.left() + } else { + ValidationResult.Invalid( + validAddress = fallbackAddress, + invalid = senderEmail, + reason = ValidateSenderAddress.ValidationError.GenericError + ).right() + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacade.kt new file mode 100644 index 0000000000..5b569da76f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacade.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import android.net.Uri +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAllAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAttachment +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ReEncryptAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreExternalAttachments +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class AttachmentsFacade @Inject constructor( + private val observeMessageAttachments: ObserveMessageAttachments, + private val storeAttachments: StoreAttachments, + private val storeExternalAttachments: StoreExternalAttachments, + private val deleteAttachment: DeleteAttachment, + private val deleteAllAttachments: DeleteAllAttachments, + private val reEncryptAttachments: ReEncryptAttachments +) { + + fun observeMessageAttachments(userId: UserId, messageId: MessageId) = + observeMessageAttachments.invoke(userId, messageId) + + suspend fun storeAttachments( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + uriList: List + ) = storeAttachments.invoke(userId, messageId, senderEmail, uriList) + + suspend fun storeExternalAttachments( + userId: UserId, + messageId: MessageId, + syncState: AttachmentSyncState = AttachmentSyncState.ExternalUploaded + ) = storeExternalAttachments.invoke(userId, messageId, syncState) + + suspend fun deleteAttachment( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + attachmentId: AttachmentId + ) = deleteAttachment.invoke(userId, senderEmail, messageId, attachmentId) + + suspend fun deleteAllAttachments( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId + ) = deleteAllAttachments.invoke(userId, senderEmail, messageId) + + suspend fun reEncryptAttachments( + userId: UserId, + messageId: MessageId, + previousSender: SenderEmail, + newSender: SenderEmail + ) = reEncryptAttachments.invoke(userId, messageId, previousSender, newSender) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacade.kt new file mode 100644 index 0000000000..1f7c4bdff3 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacade.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import arrow.core.getOrElse +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploader +import ch.protonmail.android.mailcomposer.domain.usecase.GetDecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.GetLocalMessageDecrypted +import ch.protonmail.android.mailcomposer.domain.usecase.ProvideNewDraftId +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAllFields +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithParentAttachments +import ch.protonmail.android.mailcomposer.presentation.usecase.InjectAddressSignature +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class DraftFacade @Inject constructor( + private val provideNewDraftId: ProvideNewDraftId, + private val getDecryptedDraftFields: GetDecryptedDraftFields, + private val getLocalMessageDecrypted: GetLocalMessageDecrypted, + private val parentMessageToDraftFields: ParentMessageToDraftFields, + private val storeDraftWithAllFields: StoreDraftWithAllFields, + private val storeDraftWithParentAttachments: StoreDraftWithParentAttachments, + private val injectAddressSignature: InjectAddressSignature, + private val draftUploader: DraftUploader, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher +) { + + fun provideNewDraftId() = provideNewDraftId.invoke() + + suspend fun getDecryptedDraftFields(userId: UserId, messageId: MessageId) = + getDecryptedDraftFields.invoke(userId, messageId) + + suspend fun parentMessageToDraftFields( + userId: UserId, + messageId: MessageId, + action: DraftAction + ): Pair? = withContext(defaultDispatcher) { + val parentMessage = getLocalMessageDecrypted.invoke(userId, messageId).getOrElse { + Timber.d("Failed to get local message decrypted.") + return@withContext null + } + + val fields = parentMessageToDraftFields.invoke(userId, parentMessage, action).getOrElse { + Timber.d("Failed to draft fields from parent message.") + return@withContext null + } + + parentMessage to fields + } + + suspend fun storeDraft( + userId: UserId, + draftMessageId: MessageId, + fields: DraftFields, + action: DraftAction + ) = storeDraftWithAllFields( + userId, + draftMessageId, + fields, + action + ) + + suspend fun injectAddressSignature( + userId: UserId, + draftBody: DraftBody, + senderEmail: SenderEmail, + previousSenderEmail: SenderEmail? = null + ) = injectAddressSignature.invoke(userId, draftBody, senderEmail, previousSenderEmail) + + suspend fun storeDraftWithParentAttachments( + userId: UserId, + messageId: MessageId, + parentMessage: MessageWithDecryptedBody, + senderEmail: SenderEmail, + draftAction: DraftAction + ) = storeDraftWithParentAttachments.invoke( + userId, + messageId, + parentMessage, + senderEmail, + draftAction + ) + + fun startContinuousUpload( + userId: UserId, + messageId: MessageId, + action: DraftAction, + scope: CoroutineScope + ) = draftUploader.startContinuousUpload( + userId = userId, + messageId = messageId, + action = action, + scope = scope + ) + + fun stopContinuousUpload() = draftUploader.stopContinuousUpload() + + suspend fun forceUpload(userId: UserId, messageId: MessageId) = draftUploader.upload(userId, messageId) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacade.kt new file mode 100644 index 0000000000..e37e73efeb --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacade.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessageExpirationTime +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject +import kotlin.time.Duration + +class MessageAttributesFacade @Inject constructor( + private val observeMessagePassword: ObserveMessagePassword, + private val observeMessageExpiration: ObserveMessageExpirationTime, + private val saveMessageExpirationTime: SaveMessageExpirationTime +) { + + suspend fun observeMessagePassword(userId: UserId, messageId: MessageId) = + observeMessagePassword.invoke(userId, messageId) + + suspend fun observeMessageExpiration(userId: UserId, messageId: MessageId) = + observeMessageExpiration.invoke(userId, messageId) + + suspend fun saveMessageExpiration( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + expiration: Duration + ) = saveMessageExpirationTime.invoke(userId, messageId, senderEmail, expiration) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacade.kt new file mode 100644 index 0000000000..8966a0b899 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacade.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.presentation.usecase.ConvertHtmlToPlainText +import ch.protonmail.android.mailcomposer.presentation.usecase.StyleQuotedHtml +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class MessageContentFacade @Inject constructor( + private val convertHtmlToPlainText: ConvertHtmlToPlainText, + private val styleQuotedHtml: StyleQuotedHtml, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher +) { + + suspend fun convertHtmlToPlainText(htmlString: String) = withContext(defaultDispatcher) { + convertHtmlToPlainText.invoke(htmlString) + } + + suspend fun styleQuotedHtml(quote: OriginalHtmlQuote) = withContext(defaultDispatcher) { + styleQuotedHtml.invoke(quote) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacade.kt new file mode 100644 index 0000000000..64e128584e --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacade.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.usecase.GetExternalRecipients +import ch.protonmail.android.mailcomposer.presentation.mapper.ComposerParticipantMapper +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlinx.coroutines.flow.filterNotNull +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class MessageParticipantsFacade @Inject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val participantMapper: ComposerParticipantMapper, + private val getExternalRecipients: GetExternalRecipients +) { + + fun observePrimaryUserId() = observePrimaryUserId.invoke().filterNotNull() + + suspend fun mapToParticipant(recipient: RecipientUiModel.Valid): Participant = + participantMapper.recipientUiModelToParticipant(recipient) + + suspend fun getExternalRecipients( + userId: UserId, + recipientsTo: RecipientsTo, + recipientsCc: RecipientsCc, + recipientsBcc: RecipientsBcc + ) = getExternalRecipients.invoke(userId, recipientsTo, recipientsCc, recipientsBcc) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacade.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacade.kt new file mode 100644 index 0000000000..6f38044bcb --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacade.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.ClearMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.SendMessage +import ch.protonmail.android.mailcomposer.presentation.usecase.FormatMessageSendingError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.coroutines.flow.map +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class MessageSendingFacade @Inject constructor( + private val sendMessage: SendMessage, + private val observeSendingErrors: ObserveMessageSendingError, + private val formatMessageSendingError: FormatMessageSendingError, + private val clearMessageSendingError: ClearMessageSendingError +) { + + suspend fun sendMessage( + userId: UserId, + messageId: MessageId, + fields: DraftFields, + action: DraftAction = DraftAction.Compose + ) = sendMessage.invoke(userId, messageId, fields, action) + + fun observeAndFormatSendingErrors(userId: UserId, messageId: MessageId) = + observeSendingErrors.invoke(userId, messageId).map { formatMessageSendingError.invoke(it) } + + suspend fun clearMessageSendingError(userId: UserId, messageId: MessageId) = + clearMessageSendingError.invoke(userId, messageId) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapper.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapper.kt new file mode 100644 index 0000000000..ba1ead451f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapper.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import java.util.concurrent.ConcurrentHashMap +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import me.proton.core.util.kotlin.equalsNoCase +import me.proton.core.util.kotlin.takeIfNotBlank +import timber.log.Timber +import javax.inject.Inject + +class ComposerParticipantMapper @Inject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val searchContacts: SearchContacts, + private val searchDeviceContacts: SearchDeviceContacts +) { + + private val cache = ConcurrentHashMap() + + suspend fun recipientUiModelToParticipant(recipient: RecipientUiModel.Valid): Participant { + val userId = observePrimaryUserId().firstOrNull() ?: return Participant( + recipient.address, + recipient.address, + false, + null + ) + + return cache.getOrPut(recipient.address) { + resolveRecipient(userId, recipient) + } + } + + private suspend fun resolveRecipient(userId: UserId, recipient: RecipientUiModel.Valid): Participant { + Timber.tag("ParticipantMapper").d("Resolving recipient ${recipient.hashCode()}") + val protonContactQueryResult = searchContacts.invoke(userId, recipient.address).first().getOrNull() + + val contactEmail = protonContactQueryResult?.firstNotNullOfOrNull { contact -> + contact.contactEmails.find { + // match by email and fallback to canonical version (fallback only makes sense if we actually + // get canonical version of inputted address from API, it doesn't happen yet) + recipient.address.equalsNoCase(it.email) || recipient.address.equalsNoCase(it.canonicalEmail) + } + } + + if (contactEmail != null) { + return Participant( + recipient.address, + contactEmail.name.takeIfNotBlank() ?: recipient.address, + contactEmail.isProton == true, + null + ) + } + + val deviceContact = searchDeviceContacts.invoke(recipient.address).getOrNull()?.find { contact -> + contact.email == recipient.address + } + + return Participant( + recipient.address, + deviceContact?.name?.takeIfNotBlank() ?: recipient.address, + false, + null + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapper.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapper.kt new file mode 100644 index 0000000000..7da8d6f3a9 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapper.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailmessage.domain.model.Participant +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.util.kotlin.equalsNoCase +import me.proton.core.util.kotlin.takeIfNotBlank +import javax.inject.Inject + +@Deprecated("Part of Composer V1, to be replaced with ComposerParticipantMapper") +class ParticipantMapper @Inject constructor() { + + fun recipientUiModelToParticipant(recipient: RecipientUiModel.Valid, contacts: List): Participant { + val contactEmail = contacts.firstNotNullOfOrNull { contact -> + contact.contactEmails.find { + // match by email and fallback to canonical version (fallback only makes sense if we actually + // get canonical version of inputted address from API, it doesn't happen yet) + recipient.address.equalsNoCase(it.email) || recipient.address.equalsNoCase(it.canonicalEmail) + } + } + + return Participant( + recipient.address, + contactEmail?.name?.takeIfNotBlank() ?: recipient.address, + contactEmail?.isProton ?: false, + null + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapper.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapper.kt new file mode 100644 index 0000000000..6ad7e4276a --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapper.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.ui.form.EmailValidator +import ch.protonmail.android.mailmessage.domain.model.Participant + +internal object RecipientUiModelMapper { + + fun mapFromRawValue(values: List) = values.map { it.toModel() } + fun mapFromParticipants(values: List) = values.map { it.address.toModel() } + + private fun String.toModel() = if (EmailValidator.isValidEmail(this)) { + RecipientUiModel.Valid(this) + } else { + RecipientUiModel.Invalid(this) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerBottomSheetType.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerBottomSheetType.kt new file mode 100644 index 0000000000..ab244fb7ee --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerBottomSheetType.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +internal enum class ComposerBottomSheetType { ChangeSender, SetExpirationTime } diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerChipsFieldState.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerChipsFieldState.kt new file mode 100644 index 0000000000..2be08d8ef7 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerChipsFieldState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.uicomponents.chips.ChipsListState +import ch.protonmail.android.uicomponents.chips.item.ChipItem + +internal data class ComposerChipsFieldState( + val listState: ChipsListState, + val suggestionsTermTyped: Effect = Effect.empty(), + val listChanged: Effect> = Effect.empty(), + val duplicateRemovalWarning: Effect = Effect.empty(), + val invalidEntryWarning: Effect = Effect.empty() +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerDraftState.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerDraftState.kt new file mode 100644 index 0000000000..c87f804fcd --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerDraftState.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import kotlin.time.Duration + +@Deprecated("Part of Composer V1, to be replaced with ComposerState") +data class ComposerDraftState( + val fields: ComposerFields, + val attachments: AttachmentGroupUiModel, + val premiumFeatureMessage: Effect, + val recipientValidationError: Effect, + val error: Effect, + val isSubmittable: Boolean, + val isDeviceContactsSuggestionsEnabled: Boolean, + val isDeviceContactsSuggestionsPromptEnabled: Boolean, + val senderAddresses: List, + val changeBottomSheetVisibility: Effect, + val closeComposer: Effect, + val closeComposerWithDraftSaved: Effect, + val closeComposerWithMessageSending: Effect, + val closeComposerWithMessageSendingOffline: Effect, + val confirmSendingWithoutSubject: Effect, + val changeFocusToField: Effect, + val isLoading: Boolean, + val attachmentsFileSizeExceeded: Effect, + val attachmentsReEncryptionFailed: Effect, + val replaceDraftBody: Effect, + val warning: Effect, + val isMessagePasswordSet: Boolean, + val focusTextBody: Effect = Effect.empty(), + val sendingErrorEffect: Effect = Effect.empty(), + val contactSuggestions: Map> = emptyMap(), + val areContactSuggestionsExpanded: Map = emptyMap(), + val senderChangedNotice: Effect = Effect.empty(), + val messageExpiresIn: Duration, + val confirmSendExpiringMessage: Effect>, + val openImagePicker: Effect, + val shouldRestrictWebViewHeight: Boolean +) { + + companion object { + + fun initial( + draftId: MessageId, + to: List = emptyList(), + cc: List = emptyList(), + bcc: List = emptyList(), + isSubmittable: Boolean = false + ): ComposerDraftState = ComposerDraftState( + fields = ComposerFields( + draftId = draftId, + sender = SenderUiModel(""), + to = to, + cc = cc, + bcc = bcc, + subject = "", + body = "" + ), + attachments = AttachmentGroupUiModel( + attachments = emptyList() + ), + premiumFeatureMessage = Effect.empty(), + recipientValidationError = Effect.empty(), + error = Effect.empty(), + isSubmittable = isSubmittable, + senderAddresses = emptyList(), + changeBottomSheetVisibility = Effect.empty(), + closeComposer = Effect.empty(), + closeComposerWithDraftSaved = Effect.empty(), + closeComposerWithMessageSending = Effect.empty(), + closeComposerWithMessageSendingOffline = Effect.empty(), + confirmSendingWithoutSubject = Effect.empty(), + changeFocusToField = Effect.empty(), + isLoading = false, + attachmentsFileSizeExceeded = Effect.empty(), + attachmentsReEncryptionFailed = Effect.empty(), + warning = Effect.empty(), + replaceDraftBody = Effect.empty(), + sendingErrorEffect = Effect.empty(), + isMessagePasswordSet = false, + senderChangedNotice = Effect.empty(), + messageExpiresIn = Duration.ZERO, + confirmSendExpiringMessage = Effect.empty(), + isDeviceContactsSuggestionsEnabled = false, + isDeviceContactsSuggestionsPromptEnabled = false, + openImagePicker = Effect.empty(), + shouldRestrictWebViewHeight = false + ) + } +} + +data class ComposerFields( + val draftId: MessageId, + val sender: SenderUiModel, + val to: List, + val cc: List, + val bcc: List, + val subject: String, + val body: String, + val quotedBody: QuotedHtmlContent? = null +) + +enum class ContactSuggestionsField { + TO, + CC, + BCC +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerOperation.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerOperation.kt new file mode 100644 index 0000000000..c18db399ac --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerOperation.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import android.net.Uri +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Recipient +import kotlin.time.Duration + +@Deprecated("Part of Composer V1, to be replaced with ComposerStateOperation") +sealed interface ComposerOperation + +@Deprecated("Part of Composer V1, to be replaced with ComposerAction") +internal sealed interface ComposerAction : ComposerOperation { + data class AttachmentsAdded(val uriList: List) : ComposerAction + data class SenderChanged(val sender: SenderUiModel) : ComposerAction + data class RecipientsToChanged(val recipients: List) : ComposerAction + data class RecipientsCcChanged(val recipients: List) : ComposerAction + data class RecipientsBccChanged(val recipients: List) : ComposerAction + data class ContactSuggestionTermChanged( + val searchTerm: String, + val suggestionsField: ContactSuggestionsField + ) : ComposerAction + data class ContactSuggestionsDismissed(val suggestionsField: ContactSuggestionsField) : ComposerAction + data object DeviceContactsPromptDenied : ComposerAction + data class ExpirationTimeSet(val duration: Duration) : ComposerAction + + data class SubjectChanged(val subject: Subject) : ComposerAction + data class DraftBodyChanged(val draftBody: DraftBody) : ComposerAction + data class RemoveAttachment(val attachmentId: AttachmentId) : ComposerAction + + data object ChangeSenderRequested : ComposerAction + data object OnAddAttachments : ComposerAction + data object OnCloseComposer : ComposerAction + data object OnSendMessage : ComposerAction + data object OnSetExpirationTimeRequested : ComposerAction + data object ConfirmSendingWithoutSubject : ComposerAction + data object RejectSendingWithoutSubject : ComposerAction + data object SendExpiringMessageToExternalRecipientsConfirmed : ComposerAction + data object RespondInlineRequested : ComposerAction +} + +@Deprecated("Part of Composer V1, to be replaced with ComposerStateEvent") +sealed interface ComposerEvent : ComposerOperation { + data class DefaultSenderReceived(val sender: SenderUiModel) : ComposerEvent + data class SenderAddressesReceived(val senders: List) : ComposerEvent + data class OpenExistingDraft(val draftId: MessageId) : ComposerEvent + data class OpenWithMessageAction(val parentId: MessageId, val draftAction: DraftAction) : ComposerEvent + data class PrefillDraftDataReceived( + val draftUiModel: DraftUiModel, + val isDataRefreshed: Boolean, + val isBlockedSendingFromPmAddress: Boolean, + val isBlockedSendingFromDisabledAddress: Boolean + ) : ComposerEvent + data class PrefillDataReceivedViaShare(val draftUiModel: DraftUiModel) : ComposerEvent + data class ReplaceDraftBody(val draftBody: DraftBody) : ComposerEvent + data class OnAttachmentsUpdated(val attachments: List) : ComposerEvent + data class OnSendingError(val sendingError: TextUiModel) : ComposerEvent + data class OnIsDeviceContactsSuggestionsEnabled(val enabled: Boolean) : ComposerEvent + data class OnIsDeviceContactsSuggestionsPromptEnabled(val enabled: Boolean) : ComposerEvent + data class OnMessagePasswordUpdated(val messagePassword: MessagePassword?) : ComposerEvent + data class UpdateContactSuggestions( + val contactSuggestions: List, + val suggestionsField: ContactSuggestionsField + ) : ComposerEvent + data class OnMessageExpirationTimeUpdated(val messageExpirationTime: MessageExpirationTime?) : ComposerEvent + data class ConfirmSendExpiringMessageToExternalRecipients(val externalRecipients: List) : ComposerEvent + data class RespondInlineContent(val plainText: String) : ComposerEvent + + data object ErrorLoadingDefaultSenderAddress : ComposerEvent + data object ErrorFreeUserCannotChangeSender : ComposerEvent + data object ErrorVerifyingPermissionsToChangeSender : ComposerEvent + data object ErrorStoringDraftSenderAddress : ComposerEvent + data object ErrorStoringDraftBody : ComposerEvent + data object ErrorStoringDraftRecipients : ComposerEvent + data object ErrorStoringDraftSubject : ComposerEvent + data object OnCloseWithDraftSaved : ComposerEvent + data object OnSendMessageOffline : ComposerEvent + data object ErrorLoadingDraftData : ComposerEvent + data object ErrorLoadingParentMessageData : ComposerEvent + data object ErrorAttachmentsExceedSizeLimit : ComposerEvent + data object ErrorAttachmentsReEncryption : ComposerEvent + data object ErrorSettingExpirationTime : ComposerEvent + data object ConfirmEmptySubject : ComposerEvent +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerStates.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerStates.kt new file mode 100644 index 0000000000..790691e16a --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ComposerStates.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Duration + +data class ComposerStates( + val main: ComposerState.Main, + val attachments: ComposerState.Attachments, + val accessories: ComposerState.Accessories, + val effects: ComposerState.Effects +) + +sealed interface ComposerState { + + enum class LoadingType { + Initial, + Save, + None + } + + data class Main( + val draftId: MessageId, + val senderUiModel: SenderUiModel, + val senderAddresses: ImmutableList, + val isSubmittable: Boolean, + val loadingType: LoadingType, + val quotedHtmlContent: QuotedHtmlContent? = null, + val shouldRestrictWebViewHeight: Boolean + ) { + + companion object { + + fun initial(draftId: MessageId) = Main( + draftId = draftId, + senderUiModel = SenderUiModel(""), + senderAddresses = emptyList().toImmutableList(), + isSubmittable = false, + loadingType = LoadingType.None, + shouldRestrictWebViewHeight = false + ) + } + } + + data class Attachments( + val uiModel: AttachmentGroupUiModel + ) { + + companion object { + + fun initial() = Attachments( + uiModel = AttachmentGroupUiModel(limit = NO_ATTACHMENT_LIMIT, attachments = emptyList()) + ) + } + } + + data class Accessories( + val isMessagePasswordSet: Boolean, + val messageExpiresIn: Duration + ) { + + companion object { + + fun initial() = Accessories( + isMessagePasswordSet = false, + messageExpiresIn = Duration.ZERO + ) + } + } + + data class Effects( + val error: Effect, + val exitError: Effect, + val premiumFeatureMessage: Effect, + val recipientValidationError: Effect, + val changeBottomSheetVisibility: Effect, + val closeComposer: Effect, + val closeComposerWithDraftSaved: Effect, + val closeComposerWithMessageSending: Effect, + val closeComposerWithMessageSendingOffline: Effect, + val confirmSendingWithoutSubject: Effect, + val changeFocusToField: Effect, + val attachmentsFileSizeExceeded: Effect, + val attachmentsReEncryptionFailed: Effect, + val warning: Effect, + val focusTextBody: Effect = Effect.empty(), + val sendingErrorEffect: Effect = Effect.empty(), + val senderChangedNotice: Effect = Effect.empty(), + val confirmSendExpiringMessage: Effect>, + val openImagePicker: Effect + ) { + + companion object { + + fun initial() = Effects( + error = Effect.empty(), + exitError = Effect.empty(), + premiumFeatureMessage = Effect.empty(), + recipientValidationError = Effect.empty(), + changeBottomSheetVisibility = Effect.empty(), + closeComposer = Effect.empty(), + closeComposerWithDraftSaved = Effect.empty(), + closeComposerWithMessageSending = Effect.empty(), + closeComposerWithMessageSendingOffline = Effect.empty(), + confirmSendingWithoutSubject = Effect.empty(), + changeFocusToField = Effect.empty(), + attachmentsReEncryptionFailed = Effect.empty(), + warning = Effect.empty(), + sendingErrorEffect = Effect.empty(), + senderChangedNotice = Effect.empty(), + confirmSendExpiringMessage = Effect.empty(), + openImagePicker = Effect.empty(), + attachmentsFileSizeExceeded = Effect.empty() + ) + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ContactSuggestionUiModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ContactSuggestionUiModel.kt new file mode 100644 index 0000000000..8350232899 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ContactSuggestionUiModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +sealed class ContactSuggestionUiModel( + open val name: String +) { + + data class Contact( + override val name: String, + val initial: String, + val email: String + ) : ContactSuggestionUiModel(name) + + data class ContactGroup( + override val name: String, + val emails: List, + val color: String + ) : ContactSuggestionUiModel(name) +} + diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/DraftUiModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/DraftUiModel.kt new file mode 100644 index 0000000000..f4bbe0f8a3 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/DraftUiModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent + +@Deprecated("Part of Composer V1, to be removed") +data class DraftUiModel( + val draftFields: DraftFields, + val quotedHtmlContent: QuotedHtmlContent? +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/FocusedFieldType.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/FocusedFieldType.kt new file mode 100644 index 0000000000..6b1d3627df --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/FocusedFieldType.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +enum class FocusedFieldType { + TO, + CC, + BCC, + SUBJECT, + BODY +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/MessagePasswordOperation.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/MessagePasswordOperation.kt new file mode 100644 index 0000000000..f3cfc985d4 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/MessagePasswordOperation.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword + +sealed interface MessagePasswordOperation { + sealed interface Action : MessagePasswordOperation { + data class ValidatePassword(val password: String) : Action + data class ValidateRepeatedPassword(val password: String, val repeatedPassword: String) : Action + data class ApplyPassword(val password: String, val passwordHint: String?) : Action + data class UpdatePassword(val password: String, val passwordHint: String?) : Action + object RemovePassword : Action + } + sealed interface Event : MessagePasswordOperation { + data class InitializeScreen(val messagePassword: MessagePassword?) : Event + data class PasswordValidated(val hasMessagePasswordError: Boolean) : Event + data class RepeatedPasswordValidated(val hasRepeatedMessagePasswordError: Boolean) : Event + object ExitScreen : Event + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ObserverComposerDataChanged.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ObserverComposerDataChanged.kt new file mode 100644 index 0000000000..5d8d6dff11 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/ObserverComposerDataChanged.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject + +internal data class ObservedComposerDataChanges( + val sender: SenderEmail, + val recipients: RecipientsState, + val subject: Subject, + val body: DraftBody +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientUiModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientUiModel.kt new file mode 100644 index 0000000000..0d9e274060 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientUiModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import kotlinx.collections.immutable.toImmutableList + +sealed class RecipientUiModel { + data class Valid(val address: String) : RecipientUiModel() + data class Invalid(val address: String) : RecipientUiModel() +} + +fun List.toImmutableChipList() = this.map { it.toChipItem() }.toImmutableList() + +private fun RecipientUiModel.toChipItem(): ChipItem = when (this) { + is RecipientUiModel.Invalid -> ChipItem.Invalid(address) + is RecipientUiModel.Valid -> ChipItem.Valid(address) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsState.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsState.kt new file mode 100644 index 0000000000..bfb851f11b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsState.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import androidx.compose.runtime.Stable +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +@Stable +data class RecipientsState( + val toRecipients: ImmutableList, + val ccRecipients: ImmutableList, + val bccRecipients: ImmutableList +) { + + companion object { + + val Empty = RecipientsState( + emptyList().toImmutableList(), + emptyList().toImmutableList(), + emptyList().toImmutableList() + ) + } +} + +suspend fun RecipientsState.toParticipantFields( + action: suspend (recipient: RecipientUiModel.Valid) -> Participant +): Triple, List, List> { + return coroutineScope { + val toParticipants = async { + toRecipients.mapNotNull { it as? RecipientUiModel.Valid } + .map { action(it) } + } + + val ccParticipants = async { + ccRecipients.mapNotNull { it as? RecipientUiModel.Valid } + .map { action(it) } + } + + val bccParticipants = async { + bccRecipients.mapNotNull { it as? RecipientUiModel.Valid } + .map { action(it) } + } + + Triple( + toParticipants.await(), + ccParticipants.await(), + bccParticipants.await() + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManager.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManager.kt new file mode 100644 index 0000000000..43f70374ee --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManager.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcomposer.presentation.mapper.RecipientUiModelMapper +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +class RecipientsStateManager @Inject constructor() { + + private val mutableRecipients = MutableStateFlow(RecipientsState.Empty) + val recipients = mutableRecipients.asStateFlow() + + fun updateRecipients(values: List, type: ContactSuggestionsField) { + val immutableList = values.toImmutableList() + when (type) { + ContactSuggestionsField.TO -> mutableRecipients.update { it.copy(toRecipients = immutableList) } + ContactSuggestionsField.CC -> mutableRecipients.update { it.copy(ccRecipients = immutableList) } + ContactSuggestionsField.BCC -> mutableRecipients.update { it.copy(bccRecipients = immutableList) } + } + } + + fun setFromRawRecipients( + toRecipients: List, + ccRecipients: List, + bccRecipients: List + ) { + mutableRecipients.update { + it.copy( + toRecipients = RecipientUiModelMapper.mapFromRawValue(toRecipients).toImmutableList(), + ccRecipients = RecipientUiModelMapper.mapFromRawValue(ccRecipients).toImmutableList(), + bccRecipients = RecipientUiModelMapper.mapFromRawValue(bccRecipients).toImmutableList() + ) + } + } + + fun setFromParticipants( + toRecipients: List, + ccRecipients: List, + bccRecipients: List + ) { + mutableRecipients.update { + it.copy( + toRecipients = RecipientUiModelMapper.mapFromParticipants(toRecipients).toImmutableList(), + ccRecipients = RecipientUiModelMapper.mapFromParticipants(ccRecipients).toImmutableList(), + bccRecipients = RecipientUiModelMapper.mapFromParticipants(bccRecipients).toImmutableList() + ) + } + } + + fun hasValidRecipients() = mutableRecipients.value.let { + it.toRecipients + it.ccRecipients + it.bccRecipients + }.let { list -> + list.isNotEmpty() && list.all { it is RecipientUiModel.Valid && it.address.isNotBlank() } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SendExpiringMessageDialogState.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SendExpiringMessageDialogState.kt new file mode 100644 index 0000000000..b3b1f8819b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SendExpiringMessageDialogState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailmessage.domain.model.Participant + +internal data class SendExpiringMessageDialogState( + val isVisible: Boolean, + val externalParticipants: List +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SenderUiModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SenderUiModel.kt new file mode 100644 index 0000000000..28ddbb4a7e --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SenderUiModel.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +@JvmInline +value class SenderUiModel( + val email: String +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SetMessagePasswordState.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SetMessagePasswordState.kt new file mode 100644 index 0000000000..501fcdd069 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/SetMessagePasswordState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailcommon.presentation.Effect + +sealed class SetMessagePasswordState { + + object Loading : SetMessagePasswordState() + + data class Data( + val initialMessagePasswordValue: String, + val initialMessagePasswordHintValue: String, + val hasMessagePasswordError: Boolean, + val hasRepeatedMessagePasswordError: Boolean, + val isInEditMode: Boolean, + val exitScreen: Effect + ) : SetMessagePasswordState() +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAccessoriesEvents.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAccessoriesEvents.kt new file mode 100644 index 0000000000..968f11eaae --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAccessoriesEvents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AccessoriesStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import kotlin.time.Duration + +internal sealed interface AccessoriesEvent : ComposerStateEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + accessoriesModification = when (this) { + is OnPasswordChanged -> AccessoriesStateModification.MessagePasswordUpdated(password) + is OnExpirationChanged -> + AccessoriesStateModification.MessageExpirationUpdated(expiration?.expiresIn ?: Duration.ZERO) + } + ) + + data class OnPasswordChanged(val password: MessagePassword?) : AccessoriesEvent + data class OnExpirationChanged(val expiration: MessageExpirationTime?) : AccessoriesEvent +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAction2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAction2.kt new file mode 100644 index 0000000000..bc6aa48e7b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAction2.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import android.net.Uri +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import kotlin.time.Duration + +internal sealed interface ComposerAction2 : ComposerStateOperation { + data object ChangeSender : ComposerAction2 + data class SetSenderAddress(val sender: SenderUiModel) : ComposerAction2 + + data object OpenExpirationSettings : ComposerAction2 + data class SetMessageExpiration(val duration: Duration) : ComposerAction2 + + data object RespondInline : ComposerAction2 + + data object CloseComposer : ComposerAction2 + data object SendMessage : ComposerAction2 + + data object ConfirmSendWithNoSubject : ComposerAction2 + data object CancelSendWithNoSubject : ComposerAction2 + + data object ConfirmSendExpirationSetToExternal : ComposerAction2 + data object CancelSendExpirationSetToExternal : ComposerAction2 + + data object ClearSendingError : ComposerAction2 + + data object OpenFilePicker : ComposerAction2 + data class StoreAttachments(val uriList: List) : ComposerAction2 + data class RemoveAttachment(val attachmentId: AttachmentId) : ComposerAction2 +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAttachmentsEvents.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAttachmentsEvents.kt new file mode 100644 index 0000000000..245ce3b3d0 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerAttachmentsEvents.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AttachmentsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment + +internal sealed interface AttachmentsEvent : ComposerStateEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + attachmentsModification = when (this) { + is OnListChanged -> AttachmentsStateModification.ListUpdated(list) + } + ) + + data class OnListChanged(val list: List) : AttachmentsEvent +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerCompositeEvents.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerCompositeEvents.kt new file mode 100644 index 0000000000..61de31ed8b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerCompositeEvents.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationResult +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AccessoriesStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.BottomSheetEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ConfirmationsEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ContentEffectsStateModifications +import kotlin.time.Duration + +internal sealed interface CompositeEvent : ComposerStateEvent { + + override fun toStateModifications(): ComposerStateModifications = when (this) { + is DraftContentReady -> ComposerStateModifications( + mainModification = MainStateModification.OnDraftReady( + senderEmail, + quotedHtmlContent, + shouldRestrictWebViewHeight + ), + effectsModification = ContentEffectsStateModifications.DraftContentReady( + senderValidationResult, + isDataRefreshed, + forceBodyFocus + ) + ) + + is SenderAddressesListReady -> ComposerStateModifications( + mainModification = MainStateModification.SendersListReady(sendersList), + effectsModification = BottomSheetEffectsStateModification.ShowBottomSheet + ) + + is OnSendWithEmptySubject -> ComposerStateModifications( + mainModification = MainStateModification.UpdateLoading(ComposerState.LoadingType.None), + effectsModification = ConfirmationsEffectsStateModification.SendNoSubjectConfirmationRequested + ) + + is SetExpirationDismissed -> ComposerStateModifications( + effectsModification = BottomSheetEffectsStateModification.HideBottomSheet, + accessoriesModification = AccessoriesStateModification.MessageExpirationUpdated(expiration) + ) + + is UserChangedSender -> ComposerStateModifications( + mainModification = MainStateModification.UpdateSender(newSender), + effectsModification = BottomSheetEffectsStateModification.HideBottomSheet + ) + } + + data class DraftContentReady( + val senderEmail: String, + val isDataRefreshed: Boolean, + val senderValidationResult: ValidationResult, + val quotedHtmlContent: QuotedHtmlContent?, + val shouldRestrictWebViewHeight: Boolean, + val forceBodyFocus: Boolean + ) : CompositeEvent + + data class SenderAddressesListReady(val sendersList: List) : CompositeEvent + data class UserChangedSender(val newSender: SenderEmail) : CompositeEvent + + data class SetExpirationDismissed(val expiration: Duration) : CompositeEvent + + data object OnSendWithEmptySubject : CompositeEvent +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEffectsEvent.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEffectsEvent.kt new file mode 100644 index 0000000000..433efc5500 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEffectsEvent.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.BottomSheetEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.CompletionEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ConfirmationsEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ContentEffectsStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.LoadingError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.RecoverableError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.UnrecoverableError +import ch.protonmail.android.mailmessage.domain.model.Recipient + +internal sealed interface EffectsEvent : ComposerStateEvent { + + override fun toStateModifications(): ComposerStateModifications + + sealed interface DraftEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + is OnDraftLoadingFailed -> LoadingError.DraftContent + } + ) + + data object OnDraftLoadingFailed : DraftEvent + } + + sealed interface LoadingEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + OnParentLoadingFailed -> UnrecoverableError.ParentMessageMetadata + OnSenderAddressLoadingFailed -> UnrecoverableError.InvalidSenderAddress + } + ) + + data object OnParentLoadingFailed : LoadingEvent + data object OnSenderAddressLoadingFailed : LoadingEvent + } + + sealed interface AttachmentEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + is Error -> RecoverableError.AttachmentsStore(error) + is ReEncryptError -> RecoverableError.ReEncryptAttachment + is OnAddRequest -> ContentEffectsStateModifications.OnAddAttachmentRequested + } + ) + + data class Error(val error: StoreDraftWithAttachmentError) : AttachmentEvent + data object ReEncryptError : AttachmentEvent + data object OnAddRequest : AttachmentEvent + } + + sealed interface ComposerControlEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + is OnCloseRequest -> CompletionEffectsStateModification.CloseComposer(hasDraftSaved) + is OnComposerRestored -> CompletionEffectsStateModification.CloseComposer(false) + } + ) + + data class OnCloseRequest(val hasDraftSaved: Boolean) : ComposerControlEvent + data object OnComposerRestored : ComposerControlEvent + } + + sealed interface ErrorEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + OnSenderChangeFreeUserError -> RecoverableError.SenderChange.FreeUser + OnSenderChangePermissionsError -> RecoverableError.SenderChange.UnknownPermissions + OnSetExpirationError -> RecoverableError.Expiration + } + ) + + data object OnSenderChangeFreeUserError : ErrorEvent + data object OnSenderChangePermissionsError : ErrorEvent + data object OnSetExpirationError : ErrorEvent + } + + sealed interface SendEvent : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = when (this) { + is OnCancelSendNoSubject -> ConfirmationsEffectsStateModification.CancelSendNoSubject + is OnSendExpiringToExternalRecipients -> + ConfirmationsEffectsStateModification.ShowExternalExpiringRecipients(externalRecipients) + + is OnSendMessage -> CompletionEffectsStateModification.SendMessage.SendAndExit + is OnOfflineSendMessage -> CompletionEffectsStateModification.SendMessage.SendAndExitOffline + is OnSendingError -> RecoverableError.SendingFailed(message) + } + ) + + data object OnSendMessage : SendEvent + data object OnOfflineSendMessage : SendEvent + + data object OnCancelSendNoSubject : SendEvent + data class OnSendExpiringToExternalRecipients(val externalRecipients: List) : SendEvent + data class OnSendingError(val message: String) : SendEvent + } + + data object SetExpirationReady : EffectsEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + effectsModification = BottomSheetEffectsStateModification.ShowBottomSheet + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEvent.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEvent.kt new file mode 100644 index 0000000000..ad61a5ecfe --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications + +internal sealed interface ComposerStateEvent : ComposerStateOperation { + + fun toStateModifications(): ComposerStateModifications +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerMainEvent.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerMainEvent.kt new file mode 100644 index 0000000000..b8123a4d9b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerMainEvent.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification.RemoveHtmlQuotedText +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification.UpdateLoading +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification.UpdateSender +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification.UpdateSubmittable + +internal sealed interface MainEvent : ComposerStateEvent { + + override fun toStateModifications(): ComposerStateModifications = ComposerStateModifications( + mainModification = when (this) { + is InitialLoadingToggled -> UpdateLoading(ComposerState.LoadingType.Initial) + is CoreLoadingToggled -> UpdateLoading(ComposerState.LoadingType.Save) + is LoadingDismissed -> UpdateLoading(ComposerState.LoadingType.None) + is SenderChanged -> UpdateSender(newSender) + is RecipientsChanged -> UpdateSubmittable(areSubmittable) + is OnQuotedHtmlRemoved -> RemoveHtmlQuotedText + } + ) + + data object InitialLoadingToggled : MainEvent + data object CoreLoadingToggled : MainEvent + data object LoadingDismissed : MainEvent + data class RecipientsChanged(val areSubmittable: Boolean) : MainEvent + data class SenderChanged(val newSender: SenderEmail) : MainEvent + data object OnQuotedHtmlRemoved : MainEvent +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerStateOperation.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerStateOperation.kt new file mode 100644 index 0000000000..fc9ff22d6e --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/model/operations/ComposerStateOperation.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model.operations + +internal sealed interface ComposerStateOperation diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducer.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducer.kt new file mode 100644 index 0000000000..07d04c1fe6 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducer.kt @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer + +import android.os.Build +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction +import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerEvent +import ch.protonmail.android.mailcomposer.presentation.model.ComposerOperation +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.DraftUiModel +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import javax.inject.Inject +import kotlin.time.Duration + +@Suppress("TooManyFunctions") +@Deprecated("Part of Composer V1, to be removed") +class ComposerReducer @Inject constructor( + private val attachmentUiModelMapper: AttachmentUiModelMapper, + private val shouldRestrictWebViewHeight: ShouldRestrictWebViewHeight +) { + + fun newStateFrom(currentState: ComposerDraftState, operation: ComposerOperation): ComposerDraftState = + when (operation) { + is ComposerAction -> operation.newStateForAction(currentState) + is ComposerEvent -> operation.newStateForEvent(currentState) + } + + @Suppress("ComplexMethod") + private fun ComposerAction.newStateForAction(currentState: ComposerDraftState) = when (this) { + is ComposerAction.AttachmentsAdded, + is ComposerAction.RemoveAttachment -> currentState + + is ComposerAction.SenderChanged -> updateSenderTo(currentState, this.sender) + is ComposerAction.RecipientsBccChanged -> updateRecipientsBcc(currentState, this.recipients) + is ComposerAction.RecipientsCcChanged -> updateRecipientsCc(currentState, this.recipients) + is ComposerAction.RecipientsToChanged -> updateRecipientsTo(currentState, this.recipients) + is ComposerAction.ContactSuggestionTermChanged -> currentState + is ComposerAction.DraftBodyChanged -> updateDraftBodyTo(currentState, this.draftBody) + is ComposerAction.SubjectChanged -> updateSubjectTo(currentState, this.subject) + is ComposerAction.OnAddAttachments -> updateForOnAddAttachments(currentState) + is ComposerAction.OnCloseComposer -> updateCloseComposerState(currentState, false) + is ComposerAction.ChangeSenderRequested -> currentState + is ComposerAction.OnSendMessage -> updateStateForSendMessage(currentState) + is ComposerAction.ContactSuggestionsDismissed -> updateStateForContactSuggestionsDismissed( + currentState, + this.suggestionsField + ) + + is ComposerAction.ConfirmSendingWithoutSubject -> updateForConfirmSendWithoutSubject(currentState) + is ComposerAction.RejectSendingWithoutSubject -> updateForRejectSendWithoutSubject(currentState) + is ComposerAction.OnSetExpirationTimeRequested -> updateStateForSetExpirationTimeRequested(currentState) + is ComposerAction.ExpirationTimeSet -> updateStateForExpirationTimeSet(currentState) + is ComposerAction.RespondInlineRequested, + is ComposerAction.SendExpiringMessageToExternalRecipientsConfirmed -> currentState + + is ComposerAction.DeviceContactsPromptDenied -> updateStateForDeviceContactsPromptDenied(currentState, false) + } + + @Suppress("ComplexMethod", "LongMethod") + private fun ComposerEvent.newStateForEvent(currentState: ComposerDraftState) = when (this) { + is ComposerEvent.DefaultSenderReceived -> updateSenderTo(currentState, this.sender) + is ComposerEvent.ErrorLoadingDefaultSenderAddress -> updateStateToSenderError(currentState) + is ComposerEvent.ErrorVerifyingPermissionsToChangeSender -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_change_sender_failed_getting_subscription)) + ) + + is ComposerEvent.ErrorFreeUserCannotChangeSender -> updateStateToPaidFeatureMessage(currentState) + is ComposerEvent.ErrorStoringDraftSenderAddress -> updateStateForChangeSenderFailed( + currentState = currentState, + errorMessage = TextUiModel(R.string.composer_error_store_draft_sender_address) + ) + + is ComposerEvent.ErrorStoringDraftBody -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_store_draft_body)) + ) + + is ComposerEvent.ErrorStoringDraftSubject -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_store_draft_subject)) + ) + + is ComposerEvent.ErrorStoringDraftRecipients -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_store_draft_recipients)) + ) + + is ComposerEvent.SenderAddressesReceived -> currentState.copy( + senderAddresses = this.senders, + changeBottomSheetVisibility = Effect.of(true) + ) + + is ComposerEvent.OnCloseWithDraftSaved -> updateCloseComposerState(currentState, true) + is ComposerEvent.OpenExistingDraft -> currentState.copy(isLoading = true) + is ComposerEvent.OpenWithMessageAction -> updateStateForOpenWithMessageAction(currentState, draftAction) + is ComposerEvent.PrefillDraftDataReceived -> updateComposerFieldsState( + currentState, + this.draftUiModel, + this.isDataRefreshed, + this.isBlockedSendingFromPmAddress, + this.isBlockedSendingFromDisabledAddress + ) + + is ComposerEvent.PrefillDataReceivedViaShare -> updateComposerFieldsState( + currentState, + this.draftUiModel, + isDataRefreshed = true, + blockedSendingFromPmAddress = false, + blockedSendingFromDisabledAddress = false + ) + + is ComposerEvent.ReplaceDraftBody -> { + updateReplaceDraftBodyEffect(currentState, this.draftBody) + } + + is ComposerEvent.ErrorLoadingDraftData -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_loading_draft)), + isLoading = false + ) + + is ComposerEvent.OnSendMessageOffline -> updateStateForSendMessageOffline(currentState) + is ComposerEvent.OnAttachmentsUpdated -> updateAttachmentsState(currentState, this.attachments) + is ComposerEvent.ErrorLoadingParentMessageData -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_loading_parent_message)), + isLoading = false + ) + + is ComposerEvent.ErrorAttachmentsExceedSizeLimit -> updateStateForAttachmentsExceedSizeLimit(currentState) + is ComposerEvent.ErrorAttachmentsReEncryption -> updateStateForDeleteAllAttachment(currentState) + is ComposerEvent.OnSendingError -> updateSendingErrorState(currentState, sendingError) + is ComposerEvent.OnIsDeviceContactsSuggestionsEnabled -> updateIsAccessDeviceContactsEnabledState( + currentState, + this.enabled + ) + + is ComposerEvent.UpdateContactSuggestions -> updateStateForContactSuggestions( + currentState, + this.contactSuggestions, + this.suggestionsField + ) + + is ComposerEvent.OnMessagePasswordUpdated -> updateStateForMessagePassword(currentState, this.messagePassword) + is ComposerEvent.ConfirmEmptySubject -> currentState.copy( + confirmSendingWithoutSubject = Effect.of(Unit) + ) + + is ComposerEvent.ErrorSettingExpirationTime -> currentState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)) + ) + + is ComposerEvent.OnMessageExpirationTimeUpdated -> updateStateForMessageExpirationTime( + currentState, + this.messageExpirationTime + ) + + is ComposerEvent.ConfirmSendExpiringMessageToExternalRecipients -> currentState.copy( + confirmSendExpiringMessage = Effect.of(this.externalRecipients) + ) + + is ComposerEvent.RespondInlineContent -> updateStateForRespondInline(currentState, this.plainText) + is ComposerEvent.OnIsDeviceContactsSuggestionsPromptEnabled -> currentState.copy( + isDeviceContactsSuggestionsPromptEnabled = this.enabled + ) + } + + private fun updateStateForRespondInline( + currentState: ComposerDraftState, + plainTextQuote: String + ): ComposerDraftState { + val bodyWithInlineQuote = currentState.fields.body.plus(plainTextQuote) + return currentState.copy( + fields = currentState.fields.copy(quotedBody = null), + replaceDraftBody = Effect.of(TextUiModel(bodyWithInlineQuote)) + ) + } + + private fun updateComposerFieldsState( + currentState: ComposerDraftState, + draftUiModel: DraftUiModel, + isDataRefreshed: Boolean, + blockedSendingFromPmAddress: Boolean, + blockedSendingFromDisabledAddress: Boolean + ): ComposerDraftState { + + val validToRecipients = draftUiModel.draftFields.recipientsTo.value.map { RecipientUiModel.Valid(it.address) } + val validCcRecipients = draftUiModel.draftFields.recipientsCc.value.map { RecipientUiModel.Valid(it.address) } + val validBccRecipients = draftUiModel.draftFields.recipientsBcc.value.map { RecipientUiModel.Valid(it.address) } + + return currentState.copy( + fields = currentState.fields.copy( + sender = SenderUiModel(draftUiModel.draftFields.sender.value), + subject = draftUiModel.draftFields.subject.value, + body = draftUiModel.draftFields.body.value, + quotedBody = draftUiModel.quotedHtmlContent, + to = validToRecipients, + cc = validCcRecipients, + bcc = validBccRecipients + ), + isLoading = false, + isSubmittable = (validToRecipients + validCcRecipients + validBccRecipients).isNotEmpty(), + warning = if (!isDataRefreshed) { + Effect.of(TextUiModel(R.string.composer_warning_local_data_shown)) + } else { + Effect.empty() + }, + senderChangedNotice = when { + blockedSendingFromPmAddress -> + Effect.of(TextUiModel(R.string.composer_sender_changed_pm_address_is_a_paid_feature)) + + blockedSendingFromDisabledAddress -> + Effect.of(TextUiModel(R.string.composer_sender_changed_original_address_disabled)) + + else -> Effect.empty() + }, + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight(null) && + Build.VERSION.SDK_INT == Build.VERSION_CODES.P + ) + } + + private fun updateAttachmentsState(currentState: ComposerDraftState, attachments: List) = + currentState.copy( + attachments = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = attachments.map { attachmentUiModelMapper.toUiModel(it, true) } + ) + ) + + private fun updateSendingErrorState(currentState: ComposerDraftState, sendingError: TextUiModel) = + currentState.copy(sendingErrorEffect = Effect.of(sendingError)) + + private fun updateIsAccessDeviceContactsEnabledState(currentState: ComposerDraftState, enabled: Boolean) = + currentState.copy(isDeviceContactsSuggestionsEnabled = enabled) + + private fun updateCloseComposerState(currentState: ComposerDraftState, isDraftSaved: Boolean) = if (isDraftSaved) { + currentState.copy(closeComposerWithDraftSaved = Effect.of(Unit)) + } else { + currentState.copy(closeComposer = Effect.of(Unit)) + } + + private fun updateDraftBodyTo(currentState: ComposerDraftState, draftBody: DraftBody): ComposerDraftState = + currentState.copy(fields = currentState.fields.copy(body = draftBody.value)) + + private fun updateSubjectTo(currentState: ComposerDraftState, subject: Subject): ComposerDraftState { + // New line chars make the Subject invalid on BE side. + val updatedSubject = subject.value.replace(Regex("[\\r\\n]+"), " ") + return currentState.copy(fields = currentState.fields.copy(subject = updatedSubject)) + } + + private fun updateStateForOpenWithMessageAction( + currentState: ComposerDraftState, + draftAction: DraftAction + ): ComposerDraftState { + val bodyTextFieldEffect = + if (draftAction is DraftAction.Reply || draftAction is DraftAction.ReplyAll) { + Effect.of(Unit) + } else { + Effect.empty() + } + + return currentState.copy(isLoading = true, focusTextBody = bodyTextFieldEffect) + } + + private fun updateStateForChangeSenderFailed(currentState: ComposerDraftState, errorMessage: TextUiModel) = + currentState.copy(changeBottomSheetVisibility = Effect.of(false), error = Effect.of(errorMessage)) + + private fun updateStateForSendMessage(currentState: ComposerDraftState) = + currentState.copy(closeComposerWithMessageSending = Effect.of(Unit)) + + private fun updateForConfirmSendWithoutSubject(currentState: ComposerDraftState) = currentState.copy( + closeComposerWithMessageSending = Effect.of(Unit), + confirmSendingWithoutSubject = Effect.empty() + ) + + private fun updateForRejectSendWithoutSubject(currentState: ComposerDraftState) = currentState.copy( + changeFocusToField = Effect.of(FocusedFieldType.SUBJECT), + confirmSendingWithoutSubject = Effect.empty() + ) + + private fun updateStateForSendMessageOffline(currentState: ComposerDraftState) = + currentState.copy(closeComposerWithMessageSendingOffline = Effect.of(Unit)) + + private fun updateStateToPaidFeatureMessage(currentState: ComposerDraftState) = + currentState.copy(premiumFeatureMessage = Effect.of(TextUiModel(R.string.composer_change_sender_paid_feature))) + + private fun updateStateToSenderError(currentState: ComposerDraftState) = currentState.copy( + fields = currentState.fields.copy(sender = SenderUiModel("")), + error = Effect.of(TextUiModel(R.string.composer_error_invalid_sender)) + ) + + private fun updateStateForAttachmentsExceedSizeLimit(currentState: ComposerDraftState) = + currentState.copy(attachmentsFileSizeExceeded = Effect.of(Unit)) + + private fun updateStateForDeleteAllAttachment(currentState: ComposerDraftState) = + currentState.copy(attachmentsReEncryptionFailed = Effect.of(Unit)) + + private fun updateSenderTo(currentState: ComposerDraftState, sender: SenderUiModel) = currentState.copy( + fields = currentState.fields.copy(sender = sender), + changeBottomSheetVisibility = Effect.of(false) + ) + + private fun updateReplaceDraftBodyEffect(currentState: ComposerDraftState, draftBody: DraftBody) = + currentState.copy( + replaceDraftBody = Effect.of(TextUiModel(draftBody.value)) + ) + + private fun updateStateForMessagePassword(currentState: ComposerDraftState, messagePassword: MessagePassword?) = + currentState.copy(isMessagePasswordSet = messagePassword != null) + + private fun updateStateForSetExpirationTimeRequested(currentState: ComposerDraftState) = + currentState.copy(changeBottomSheetVisibility = Effect.of(true)) + + private fun updateStateForExpirationTimeSet(currentState: ComposerDraftState) = + currentState.copy(changeBottomSheetVisibility = Effect.of(false)) + + private fun updateStateForDeviceContactsPromptDenied(currentState: ComposerDraftState, enabled: Boolean) = + currentState.copy(isDeviceContactsSuggestionsPromptEnabled = enabled) + + private fun updateStateForMessageExpirationTime( + currentState: ComposerDraftState, + messageExpirationTime: MessageExpirationTime? + ) = currentState.copy(messageExpiresIn = messageExpirationTime?.expiresIn ?: Duration.ZERO) + + private fun updateForOnAddAttachments(currentState: ComposerDraftState) = currentState.copy( + openImagePicker = Effect.of(Unit) + ) + + private fun updateRecipientsTo( + currentState: ComposerDraftState, + recipients: List + ): ComposerDraftState = updateRecipients( + currentState = currentState, + to = recipients, + cc = currentState.fields.cc, + bcc = currentState.fields.bcc + ) + + private fun updateRecipientsCc( + currentState: ComposerDraftState, + recipients: List + ): ComposerDraftState = updateRecipients( + currentState = currentState, + to = currentState.fields.to, + cc = recipients, + bcc = currentState.fields.bcc + ) + + private fun updateRecipientsBcc( + currentState: ComposerDraftState, + recipients: List + ): ComposerDraftState = updateRecipients( + currentState = currentState, + to = currentState.fields.to, + cc = currentState.fields.cc, + bcc = recipients + ) + + private fun updateStateForContactSuggestions( + currentState: ComposerDraftState, + contactSuggestions: List, + suggestionsField: ContactSuggestionsField + ) = currentState.copy( + contactSuggestions = currentState.contactSuggestions.toMutableMap().apply { + this[suggestionsField] = contactSuggestions + }, + areContactSuggestionsExpanded = currentState.areContactSuggestionsExpanded.toMutableMap().apply { + this[suggestionsField] = contactSuggestions.isNotEmpty() + } + ) + + @Suppress("FunctionMaxLength") + private fun updateStateForContactSuggestionsDismissed( + currentState: ComposerDraftState, + suggestionsField: ContactSuggestionsField + ): ComposerDraftState = currentState.copy( + areContactSuggestionsExpanded = currentState.areContactSuggestionsExpanded.toMutableMap().apply { + this[suggestionsField] = false + } + ) + + private fun updateRecipients( + currentState: ComposerDraftState, + to: List, + cc: List, + bcc: List + ): ComposerDraftState { + val allValid = (to + cc + bcc).all { it is RecipientUiModel.Valid } + val notEmpty = (to + cc + bcc).isNotEmpty() + val hasInvalidRecipients = hasInvalidRecipients(to, cc, bcc, currentState) + + val capturedToDuplicates = captureDuplicateEmails(to) + val capturedCcDuplicates = captureDuplicateEmails(cc) + val capturedBccDuplicates = captureDuplicateEmails(bcc) + val hasDuplicates = hasDuplicates(capturedToDuplicates, capturedCcDuplicates, capturedBccDuplicates) + + val error = when { + hasDuplicates -> { + Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + } + + hasInvalidRecipients -> Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + else -> Effect.empty() + } + + return currentState.copy( + fields = currentState.fields.copy( + to = capturedToDuplicates.cleanedRecipients, + cc = capturedCcDuplicates.cleanedRecipients, + bcc = capturedBccDuplicates.cleanedRecipients + ), + recipientValidationError = error, + isSubmittable = allValid && notEmpty + ) + } + + private fun hasDuplicates( + capturedToDuplicates: CleanedRecipients, + capturedCcDuplicates: CleanedRecipients, + capturedBccDuplicates: CleanedRecipients + ): Boolean = capturedToDuplicates.duplicatesFound.isNotEmpty() || + capturedCcDuplicates.duplicatesFound.isNotEmpty() || + capturedBccDuplicates.duplicatesFound.isNotEmpty() + + private fun captureDuplicateEmails(recipients: List): CleanedRecipients { + val itemsCounted = recipients.groupingBy { it }.eachCount() + return CleanedRecipients( + itemsCounted.map { it.key }, + itemsCounted.filter { it.value > 1 }.map { it.key } + ) + } + + // For now we consider an error state if the last recipient is invalid and we have not deleted a recipient + private fun hasInvalidRecipients( + to: List, + cc: List, + bcc: List, + currentState: ComposerDraftState + ): Boolean { + return hasError(to, currentState.fields.to) || + hasError(cc, currentState.fields.cc) || + hasError(bcc, currentState.fields.bcc) + } + + private fun hasError(newRecipients: List, currentRecipients: List) = + newRecipients.size > currentRecipients.size && newRecipients.lastOrNull() is RecipientUiModel.Invalid + + private data class CleanedRecipients( + val cleanedRecipients: List, + val duplicatesFound: List + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerStateReducer.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerStateReducer.kt new file mode 100644 index 0000000000..277c2d6dea --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerStateReducer.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer + +import ch.protonmail.android.mailcomposer.presentation.model.ComposerStates +import ch.protonmail.android.mailcomposer.presentation.model.operations.ComposerStateEvent +import javax.inject.Inject + +class ComposerStateReducer @Inject constructor() { + + internal fun reduceNewState(currentStates: ComposerStates, event: ComposerStateEvent): ComposerStates { + val modifications = event.toStateModifications() + + return currentStates.copy( + main = modifications.mainModification?.apply(currentStates.main) + ?: currentStates.main, + attachments = modifications.attachmentsModification?.apply(currentStates.attachments) + ?: currentStates.attachments, + accessories = modifications.accessoriesModification?.apply(currentStates.accessories) + ?: currentStates.accessories, + effects = modifications.effectsModification?.apply(currentStates.effects) + ?: currentStates.effects + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducer.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducer.kt new file mode 100644 index 0000000000..87c923c003 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducer.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.presentation.model.MessagePasswordOperation +import ch.protonmail.android.mailcomposer.presentation.model.SetMessagePasswordState +import me.proton.core.util.kotlin.EMPTY_STRING +import javax.inject.Inject + +class SetMessagePasswordReducer @Inject constructor() { + + fun newStateFrom( + currentState: SetMessagePasswordState, + event: MessagePasswordOperation.Event + ): SetMessagePasswordState = when (event) { + is MessagePasswordOperation.Event.ExitScreen -> newStateForExitScreen(currentState) + is MessagePasswordOperation.Event.InitializeScreen -> newStateForPrefillInputFields(event) + is MessagePasswordOperation.Event.PasswordValidated -> newStateForPasswordValidated(currentState, event) + is MessagePasswordOperation.Event.RepeatedPasswordValidated -> + newStateForRepeatedPasswordValidated(currentState, event) + } + + private fun newStateForExitScreen(currentState: SetMessagePasswordState): SetMessagePasswordState = + if (currentState is SetMessagePasswordState.Data) { + currentState.copy(exitScreen = Effect.of(Unit)) + } else { + currentState + } + + private fun newStateForPrefillInputFields( + event: MessagePasswordOperation.Event.InitializeScreen + ): SetMessagePasswordState = SetMessagePasswordState.Data( + initialMessagePasswordValue = event.messagePassword?.password ?: EMPTY_STRING, + initialMessagePasswordHintValue = event.messagePassword?.passwordHint ?: EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = event.messagePassword != null, + exitScreen = Effect.empty() + ) + + private fun newStateForPasswordValidated( + currentState: SetMessagePasswordState, + event: MessagePasswordOperation.Event.PasswordValidated + ) = if (currentState is SetMessagePasswordState.Data) { + currentState.copy(hasMessagePasswordError = event.hasMessagePasswordError) + } else { + currentState + } + + private fun newStateForRepeatedPasswordValidated( + currentState: SetMessagePasswordState, + event: MessagePasswordOperation.Event.RepeatedPasswordValidated + ) = if (currentState is SetMessagePasswordState.Data) { + currentState.copy(hasRepeatedMessagePasswordError = event.hasRepeatedMessagePasswordError) + } else { + currentState + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AccessoriesStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AccessoriesStateModification.kt new file mode 100644 index 0000000000..1150f561ac --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AccessoriesStateModification.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications + +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import kotlin.time.Duration + +internal sealed interface AccessoriesStateModification : ComposerStateModification { + + override fun apply(state: ComposerState.Accessories): ComposerState.Accessories = when (this) { + is MessageExpirationUpdated -> state.copy(messageExpiresIn = expiration) + is MessagePasswordUpdated -> state.copy(isMessagePasswordSet = messagePassword != null) + } + + data class MessagePasswordUpdated(val messagePassword: MessagePassword?) : AccessoriesStateModification + data class MessageExpirationUpdated(val expiration: Duration) : AccessoriesStateModification +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AttachmentsStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AttachmentsStateModification.kt new file mode 100644 index 0000000000..a4bb161b9f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/AttachmentsStateModification.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications + +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper2 +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT + +internal sealed interface AttachmentsStateModification : ComposerStateModification { + data class ListUpdated(val list: List) : AttachmentsStateModification { + + override fun apply(state: ComposerState.Attachments): ComposerState.Attachments = state.copy( + uiModel = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = list.map { AttachmentUiModelMapper2.toUiModel(it, true) } + ) + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/ComposerStateModifications.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/ComposerStateModifications.kt new file mode 100644 index 0000000000..98366d828a --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/ComposerStateModifications.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications + +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.EffectsStateModification + +internal interface ComposerStateModification { + + fun apply(state: T): T +} + +internal data class ComposerStateModifications( + val mainModification: MainStateModification? = null, + val attachmentsModification: AttachmentsStateModification? = null, + val accessoriesModification: AccessoriesStateModification? = null, + val effectsModification: EffectsStateModification? = null +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/MainStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/MainStateModification.kt new file mode 100644 index 0000000000..00f16d7577 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/MainStateModification.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications + +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import kotlinx.collections.immutable.toImmutableList + +internal sealed interface MainStateModification : ComposerStateModification { + + data class OnDraftReady( + val sender: String, + val quotedHtmlContent: QuotedHtmlContent?, + val shouldRestrictWebViewHeight: Boolean + ) : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = state.copy( + senderUiModel = SenderUiModel(sender), + quotedHtmlContent = quotedHtmlContent, + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight + ) + } + + data class UpdateLoading(val value: ComposerState.LoadingType) : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = state.copy(loadingType = value) + } + + data class UpdateSender(val sender: SenderEmail) : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = + state.copy(senderUiModel = SenderUiModel(sender.value)) + } + + data class SendersListReady(val list: List) : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = + state.copy(senderAddresses = list.toImmutableList()) + } + + data class UpdateSubmittable(val isSubmittable: Boolean) : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = state.copy(isSubmittable = isSubmittable) + } + + data object RemoveHtmlQuotedText : MainStateModification { + + override fun apply(state: ComposerState.Main): ComposerState.Main = state.copy(quotedHtmlContent = null) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/BottomSheetEffectsStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/BottomSheetEffectsStateModification.kt new file mode 100644 index 0000000000..92033ee9df --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/BottomSheetEffectsStateModification.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState + +internal sealed interface BottomSheetEffectsStateModification : EffectsStateModification { + data object ShowBottomSheet : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(changeBottomSheetVisibility = Effect.of(true)) + } + + data object HideBottomSheet : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(changeBottomSheetVisibility = Effect.of(false)) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/CompletionEffectsStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/CompletionEffectsStateModification.kt new file mode 100644 index 0000000000..af3b8c9ca5 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/CompletionEffectsStateModification.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState + +internal sealed interface CompletionEffectsStateModification : EffectsStateModification { + data class CloseComposer(val hasSavedDraft: Boolean) : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + if (hasSavedDraft) state.copy(closeComposerWithDraftSaved = Effect.of(Unit)) + else state.copy(closeComposer = Effect.of(Unit)) + } + + sealed interface SendMessage : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = when (this) { + SendAndExit -> state.copy(closeComposerWithMessageSending = Effect.of(Unit)) + SendAndExitOffline -> state.copy(closeComposerWithMessageSendingOffline = Effect.of(Unit)) + } + + data object SendAndExit : SendMessage + data object SendAndExitOffline : SendMessage + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ConfirmationEffectsStateModifications.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ConfirmationEffectsStateModifications.kt new file mode 100644 index 0000000000..7beb5a30ee --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ConfirmationEffectsStateModifications.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailmessage.domain.model.Recipient + +internal sealed interface ConfirmationsEffectsStateModification : EffectsStateModification { + data object SendNoSubjectConfirmationRequested : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(confirmSendingWithoutSubject = Effect.of(Unit)) + } + + data object CancelSendNoSubject : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = state.copy( + changeFocusToField = Effect.of(FocusedFieldType.SUBJECT), + confirmSendingWithoutSubject = Effect.empty() + ) + } + + data class ShowExternalExpiringRecipients(val externalRecipients: List) : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(confirmSendExpiringMessage = Effect.of(externalRecipients)) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ContentEffectsStateModifications.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ContentEffectsStateModifications.kt new file mode 100644 index 0000000000..0599368877 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ContentEffectsStateModifications.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState + +internal sealed interface ContentEffectsStateModifications : EffectsStateModification { + data object OnAddAttachmentRequested : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(openImagePicker = Effect.of(Unit)) + } + + data class DraftContentReady( + val validationResult: ValidateSenderAddress.ValidationResult, + val isDataRefresh: Boolean, + val forceBodyFocus: Boolean + ) : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = state.copy( + senderChangedNotice = validationResult.toSenderNotice(), + warning = createWarningIfNotRefreshed(isDataRefresh), + focusTextBody = if (forceBodyFocus) Effect.of(Unit) else Effect.empty() + ) + + private fun ValidateSenderAddress.ValidationResult.toSenderNotice(): Effect = when (this) { + is ValidateSenderAddress.ValidationResult.Invalid -> when (reason) { + ValidateSenderAddress.ValidationError.PaidAddress -> + Effect.of(TextUiModel(R.string.composer_sender_changed_pm_address_is_a_paid_feature)) + + ValidateSenderAddress.ValidationError.DisabledAddress -> + Effect.of(TextUiModel(R.string.composer_sender_changed_original_address_disabled)) + + ValidateSenderAddress.ValidationError.GenericError -> + Effect.of(TextUiModel(R.string.composer_sender_changed_original_address_generic_error)) + } + + is ValidateSenderAddress.ValidationResult.Valid -> Effect.empty() + } + + private fun createWarningIfNotRefreshed(isDataRefresh: Boolean): Effect = + if (!isDataRefresh) Effect.of(TextUiModel(R.string.composer_warning_local_data_shown)) + else Effect.empty() + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/EffectsStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/EffectsStateModification.kt new file mode 100644 index 0000000000..3276927db1 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/EffectsStateModification.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModification + +internal sealed interface EffectsStateModification : ComposerStateModification diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ErrorEffectsStateModification.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ErrorEffectsStateModification.kt new file mode 100644 index 0000000000..5434c84f56 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/modifications/effects/ErrorEffectsStateModification.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects + +import androidx.annotation.StringRes +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState + +internal sealed class LoadingError(@StringRes val resId: Int) : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(error = Effect.of(TextUiModel(resId))) + + data object DraftContent : LoadingError(R.string.composer_error_loading_draft) +} + +internal sealed class UnrecoverableError(@StringRes val resId: Int) : EffectsStateModification { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(exitError = Effect.of(TextUiModel(resId))) + + data object InvalidSenderAddress : UnrecoverableError(R.string.composer_error_invalid_sender) + data object ParentMessageMetadata : UnrecoverableError(R.string.composer_error_loading_parent_message) +} + +internal sealed interface RecoverableError : EffectsStateModification { + + sealed class SenderChange(@StringRes val resId: Int) : RecoverableError { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = when (this) { + FreeUser -> state.copy(premiumFeatureMessage = Effect.of(TextUiModel(resId))) + UnknownPermissions -> state.copy(error = Effect.of(TextUiModel(resId))) + } + + data object FreeUser : SenderChange(R.string.composer_change_sender_paid_feature) + data object UnknownPermissions : + SenderChange(R.string.composer_error_change_sender_failed_getting_subscription) + } + + data class AttachmentsStore(val error: StoreDraftWithAttachmentError) : RecoverableError { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = when (error) { + StoreDraftWithAttachmentError.AttachmentsMissing, + StoreDraftWithAttachmentError.AttachmentFileMissing -> + state.copy(error = Effect.of(TextUiModel.TextRes(R.string.composer_attachment_not_found))) + + StoreDraftWithAttachmentError.FailedReceivingDraft -> + state.copy(error = Effect.of(TextUiModel.TextRes(R.string.composer_attachment_error_draft_loading))) + + StoreDraftWithAttachmentError.FailedToStoreAttachments -> + state.copy( + error = Effect.of(TextUiModel.TextRes(R.string.composer_attachment_error_saving_attachment)) + ) + + StoreDraftWithAttachmentError.FileSizeExceedsLimit -> + state.copy(attachmentsFileSizeExceeded = Effect.of(Unit)) + } + } + + data object ReEncryptAttachment : RecoverableError { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = state.copy( + attachmentsReEncryptionFailed = Effect.of( + TextUiModel(R.string.composer_attachment_reencryption_failed_message) + ) + ) + } + + data object Expiration : RecoverableError { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = state.copy( + error = Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)), + changeBottomSheetVisibility = Effect.of(false) + ) + } + + data class SendingFailed(val reason: String) : RecoverableError { + + override fun apply(state: ComposerState.Effects): ComposerState.Effects = + state.copy(sendingErrorEffect = Effect.of(TextUiModel.Text(reason))) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyHtmlQuote.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyHtmlQuote.kt new file mode 100644 index 0000000000..2b77c44965 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyHtmlQuote.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.presentation.model.MessageBodyExpandCollapseMode +import ch.protonmail.android.mailmessage.presentation.model.MessageBodyUiModel +import ch.protonmail.android.mailmessage.presentation.model.MimeTypeUiModel +import ch.protonmail.android.mailmessage.presentation.model.ViewModePreference +import ch.protonmail.android.mailmessage.presentation.ui.MessageBodyWebView + +@Composable +internal fun BodyHtmlQuote( + value: String, + shouldRestrictWebViewHeight: Boolean, + modifier: Modifier = Modifier +) { + val uiModel = remember { buildFakeMessageBodyUiModel(value, shouldRestrictWebViewHeight) } + MessageBodyWebView( + modifier = modifier, + messageBodyUiModel = uiModel, + bodyDisplayMode = MessageBodyExpandCollapseMode.NotApplicable, + shouldAllowViewingEntireMessage = false, + webViewActions = MessageBodyWebView.Actions( + onMessageBodyLinkClicked = {}, + onMessageBodyLinkLongClicked = {}, + onExpandCollapseButtonCLicked = {}, + loadEmbeddedImage = { _, _ -> null }, + onPrint = {}, + onViewEntireMessageClicked = { _, _, _, _ -> }, + onReply = {}, + onReplyAll = {}, + onForward = {} + ) + ) +} + +private fun buildFakeMessageBodyUiModel(body: String, shouldRestrictWebViewHeight: Boolean) = MessageBodyUiModel( + MessageId("fake-message-id-for-quoted-message-body"), + body, + messageBodyWithoutQuote = body, + MimeTypeUiModel.Html, + shouldShowEmbeddedImages = false, + shouldShowRemoteContent = false, + shouldShowEmbeddedImagesBanner = false, + shouldShowRemoteContentBanner = false, + shouldShowExpandCollapseButton = false, + shouldShowOpenInProtonCalendar = false, + attachments = null, + userAddress = null, + viewModePreference = ViewModePreference.ThemeDefault, + printEffect = Effect.empty(), + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight, + replyEffect = Effect.empty(), + replyAllEffect = Effect.empty(), + forwardEffect = Effect.empty() +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField.kt new file mode 100644 index 0000000000..b311197562 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.presentation.R +import kotlinx.coroutines.flow.collectLatest +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm + +@Composable +@Deprecated("Part of Composer V1, to be replaced with BodyTextField2") +internal fun BodyTextField( + initialValue: String, + replaceDraftBody: Effect, + shouldRequestFocus: Effect, + onBodyChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + val orientation = LocalConfiguration.current.orientation + + var shouldFocus by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(false) } + val textFieldState = rememberTextFieldState(initialValue, initialSelection = TextRange.Zero) + + // No need for derivedStateOf as it's a simple comparison, nothing heavy. + val bodyMinLines = if (orientation == Configuration.ORIENTATION_PORTRAIT) { + Composer.MessageBodyPortraitMinLines + } else { + 1 + } + + BasicTextField( + state = textFieldState, + modifier = modifier + .fillMaxSize() + .padding(ProtonDimens.DefaultSpacing) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .onGloballyPositioned { + if (shouldFocus) { + focusRequester.requestFocus() + shouldFocus = false + } + }, + keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences), + textStyle = ProtonTheme.typography.defaultNorm, + cursorBrush = SolidColor(TextFieldDefaults.colors().cursorColor), + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = bodyMinLines), + decorator = @Composable { innerTextField -> + if (textFieldState.text.isEmpty()) { + PlaceholderText() + } + + innerTextField() + } + ) + + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text } + .collectLatest { + onBodyChange(it.toString()) + } + } + + ConsumableLaunchedEffect(shouldRequestFocus) { + shouldFocus = true + } + + ConsumableTextEffect(effect = replaceDraftBody) { + textFieldState.edit { + replace(0, originalText.length, it) + selection = TextRange.Zero + } + onBodyChange(it) + } +} + +@Composable +private fun PlaceholderText() { + Text( + modifier = Modifier.testTag(ComposerTestTags.MessageBodyPlaceholder), + text = stringResource(R.string.compose_message_placeholder), + color = ProtonTheme.colors.textHint, + style = ProtonTheme.typography.defaultNorm + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField2.kt new file mode 100644 index 0000000000..08cb096196 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/BodyTextField2.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm + +@Composable +internal fun BodyTextField2( + textFieldState: TextFieldState, + shouldRequestFocus: Effect, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + val orientation = LocalConfiguration.current.orientation + + var shouldFocus by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(false) } + + val bodyMinLines = if (orientation == Configuration.ORIENTATION_PORTRAIT) { + Composer.MessageBodyPortraitMinLines + } else { + 1 + } + + BasicTextField( + state = textFieldState, + modifier = modifier + .fillMaxSize() + .padding(ProtonDimens.DefaultSpacing) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .onGloballyPositioned { + if (shouldFocus) { + focusRequester.requestFocus() + shouldFocus = false + } + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text + ), + textStyle = ProtonTheme.typography.defaultNorm, + cursorBrush = SolidColor(TextFieldDefaults.colors().cursorColor), + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = bodyMinLines), + decorator = @Composable { innerTextField -> + if (textFieldState.text.isEmpty()) { + PlaceholderText() + } + + innerTextField() + } + ) + + ConsumableLaunchedEffect(shouldRequestFocus) { + shouldFocus = true + } +} + +@Composable +private fun PlaceholderText() { + Text( + modifier = Modifier.testTag(ComposerTestTags.MessageBodyPlaceholder), + text = stringResource(R.string.compose_message_placeholder), + color = ProtonTheme.colors.textHint, + style = ProtonTheme.typography.defaultNorm + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ChangeSenderBottomSheetContent.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ChangeSenderBottomSheetContent.kt new file mode 100644 index 0000000000..bc14896b1b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ChangeSenderBottomSheetContent.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import me.proton.core.compose.component.ProtonRawListItem +import me.proton.core.compose.theme.ProtonDimens + +@Composable +fun ChangeSenderBottomSheetContent( + addresses: List, + onSenderSelected: (SenderUiModel) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn(modifier = modifier.testTag(ChangeSenderBottomSheetTestTags.Root)) { + itemsIndexed(addresses) { index, item -> + ProtonRawListItem( + modifier = Modifier + .testTag("${ChangeSenderBottomSheetTestTags.Item}$index") + .clickable { onSenderSelected(item) } + .height(ProtonDimens.ListItemHeight) + .padding(horizontal = ProtonDimens.DefaultSpacing) + ) { + Text( + modifier = Modifier.weight(1f), + text = item.email, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer( + modifier = Modifier.size(ProtonDimens.SmallSpacing) + ) + } + } + } +} + +object ChangeSenderBottomSheetTestTags { + + const val Root = "ChangeSenderBottomSheet" + const val Item = "ChangeSenderItem" +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerBottomBar.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerBottomBar.kt new file mode 100644 index 0000000000..f3c7b8d9b6 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerBottomBar.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcommon.presentation.compose.MailDimens +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme + +@Composable +fun ComposerBottomBar( + draftId: MessageId, + senderEmail: SenderEmail, + isMessagePasswordSet: Boolean, + isMessageExpirationTimeSet: Boolean, + onSetMessagePasswordClick: (MessageId, SenderEmail) -> Unit, + onSetExpirationTimeClick: () -> Unit, + enableInteractions: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + HorizontalDivider(color = ProtonTheme.colors.separatorNorm, thickness = MailDimens.SeparatorHeight) + Row( + modifier = Modifier + .fillMaxWidth() + .height(MailDimens.ExtraLargeSpacing) + .padding(horizontal = ProtonDimens.ExtraSmallSpacing), + verticalAlignment = Alignment.CenterVertically + ) { + AddPasswordButton(draftId, senderEmail, isMessagePasswordSet, enableInteractions, onSetMessagePasswordClick) + SetExpirationButton(isMessageExpirationTimeSet, enableInteractions, onSetExpirationTimeClick) + } + } +} + +@Composable +private fun AddPasswordButton( + draftId: MessageId, + senderEmail: SenderEmail, + isMessagePasswordSet: Boolean, + isEnabled: Boolean, + onSetMessagePasswordClick: (MessageId, SenderEmail) -> Unit +) { + BottomBarButton( + iconRes = R.drawable.ic_proton_lock, + contentDescriptionRes = R.string.composer_button_add_password, + shouldShowCheckmark = isMessagePasswordSet, + onClick = { onSetMessagePasswordClick(draftId, senderEmail) }, + isEnabled = isEnabled + ) +} + +@Composable +private fun SetExpirationButton( + isMessageExpirationTimeSet: Boolean, + isEnabled: Boolean, + onSetExpirationTimeClick: () -> Unit +) { + BottomBarButton( + iconRes = R.drawable.ic_proton_hourglass, + contentDescriptionRes = R.string.composer_button_set_expiration, + shouldShowCheckmark = isMessageExpirationTimeSet, + onClick = onSetExpirationTimeClick, + isEnabled = isEnabled + ) +} + +@Composable +private fun BottomBarButton( + @DrawableRes iconRes: Int, + @StringRes contentDescriptionRes: Int, + shouldShowCheckmark: Boolean, + onClick: () -> Unit, + isEnabled: Boolean +) { + Box { + EnabledStateIconButton( + icon = painterResource(id = iconRes), + isEnabled = isEnabled, + contentDescription = stringResource(id = contentDescriptionRes), + onClick = onClick + ) + + if (shouldShowCheckmark) { + Box( + modifier = Modifier + .size(MailDimens.ExtraLargeSpacing) + .padding(bottom = ProtonDimens.SmallSpacing, end = ProtonDimens.ExtraSmallSpacing), + contentAlignment = Alignment.BottomEnd + ) { + BottomBarButtonCheckmark() + } + } + } +} + +@Composable +private fun BottomBarButtonCheckmark(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(ProtonDimens.SmallIconSize) + .background(ProtonTheme.colors.interactionNorm, CircleShape) + .border(Dp.Hairline, ProtonTheme.colors.backgroundNorm, CircleShape) + .padding(ProtonDimens.ExtraSmallSpacing), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_proton_checkmark), + contentDescription = NO_CONTENT_DESCRIPTION, + tint = ProtonTheme.colors.iconInverted + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerForm.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerForm.kt new file mode 100644 index 0000000000..03d1615fc0 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerForm.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.compose.FocusableForm +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.ui.MailDivider +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.uicomponents.keyboardVisibilityAsState +import me.proton.core.compose.theme.ProtonDimens +import timber.log.Timber + +@Composable +@Deprecated("Part of Composer V1, to be replaced with ComposerForm2") +internal fun ComposerForm( + emailValidator: (String) -> Boolean, + recipientsOpen: Boolean, + initialFocus: FocusedFieldType, + changeFocusToField: Effect, + fields: ComposerFields, + replaceDraftBody: Effect, + shouldForceBodyTextFocus: Effect, + actions: ComposerFormActions, + contactSuggestions: Map>, + areContactSuggestionsExpanded: Map, + shouldRestrictWebViewHeight: Boolean, + modifier: Modifier = Modifier +) { + val isKeyboardVisible by keyboardVisibilityAsState() + val keyboardController = LocalSoftwareKeyboardController.current + val maxWidthModifier = Modifier.fillMaxWidth() + + var showSubjectAndBody by remember { mutableStateOf(true) } + + // Handle visibility of body and subject here, to avoid issues with focus requesters. + LaunchedEffect(areContactSuggestionsExpanded) { + showSubjectAndBody = !areContactSuggestionsExpanded.any { it.value } + } + + FocusableForm( + fieldList = listOf( + FocusedFieldType.TO, + FocusedFieldType.CC, + FocusedFieldType.BCC, + FocusedFieldType.SUBJECT, + FocusedFieldType.BODY + ), + initialFocus = initialFocus, + onFocusedField = { + Timber.d("Focus changed: onFocusedField: $it") + actions.onFocusChanged(it) + } + ) { fieldFocusRequesters -> + + ConsumableLaunchedEffect(effect = changeFocusToField) { + fieldFocusRequesters[it]?.requestFocus() + if (!isKeyboardVisible) { + keyboardController?.show() + } + } + + Column( + modifier = modifier.fillMaxWidth() + ) { + PrefixedEmailSelector( + prefixStringResource = R.string.from_prefix, + modifier = maxWidthModifier.testTag(ComposerTestTags.FromSender), + selectedEmail = fields.sender.email, + actions.onChangeSender + ) + MailDivider() + + RecipientFields( + fields = fields, + fieldFocusRequesters = fieldFocusRequesters, + recipientsOpen = recipientsOpen, + emailValidator = emailValidator, + contactSuggestions = contactSuggestions, + areContactSuggestionsExpanded = areContactSuggestionsExpanded, + actions = actions + ) + + if (showSubjectAndBody) { + MailDivider() + SubjectTextField( + initialValue = fields.subject, + onSubjectChange = actions.onSubjectChanged, + modifier = maxWidthModifier + .padding(ProtonDimens.DefaultSpacing) + .testTag(ComposerTestTags.Subject) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.SUBJECT) + ) + MailDivider() + + BodyTextField( + initialValue = fields.body, + shouldRequestFocus = shouldForceBodyTextFocus, + replaceDraftBody = replaceDraftBody, + onBodyChange = actions.onBodyChanged, + modifier = maxWidthModifier + .testTag(ComposerTestTags.MessageBody) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.BODY) + ) + + if (fields.quotedBody != null) { + RespondInlineButton(actions.onRespondInline) + BodyHtmlQuote( + value = fields.quotedBody.styled.value, + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight, + modifier = maxWidthModifier.testTag(ComposerTestTags.MessageHtmlQuotedBody) + ) + } + } + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerFormActions.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerFormActions.kt new file mode 100644 index 0000000000..cb0f41f381 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerFormActions.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel + +@Deprecated("Part of Composer V1, to be removed") +internal data class ComposerFormActions( + val onToggleRecipients: (Boolean) -> Unit, + val onFocusChanged: (FocusedFieldType) -> Unit, + val onToChanged: (List) -> Unit, + val onCcChanged: (List) -> Unit, + val onBccChanged: (List) -> Unit, + val onContactSuggestionsDismissed: (ContactSuggestionsField) -> Unit, + val onDeviceContactsPromptDenied: () -> Unit, + val onContactSuggestionTermChanged: (String, ContactSuggestionsField) -> Unit, + val onSubjectChanged: (String) -> Unit, + val onBodyChanged: (String) -> Unit, + val onChangeSender: () -> Unit, + val onRespondInline: () -> Unit +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen.kt new file mode 100644 index 0000000000..63c273be81 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen.kt @@ -0,0 +1,494 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import android.Manifest +import android.text.format.Formatter +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailcommon.presentation.AdaptivePreviews +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction +import ch.protonmail.android.mailcomposer.presentation.model.ComposerBottomSheetType +import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.SendExpiringMessageDialogState +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooter +import ch.protonmail.android.uicomponents.bottomsheet.bottomSheetHeightConstrainedContent +import ch.protonmail.android.uicomponents.dismissKeyboard +import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.compose.component.ProtonModalBottomSheetLayout +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType +import me.proton.core.compose.theme.ProtonTheme3 +import timber.log.Timber +import kotlin.time.Duration + +@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class) +@Suppress("UseComposableActions") +@Composable +@Deprecated("Part of Composer V1, to be replaced with ComposerScreen2") +fun ComposerScreen(actions: ComposerScreen.Actions, viewModel: ComposerViewModel = hiltViewModel()) { + val context = LocalContext.current + val view = LocalView.current + val keyboardController = LocalSoftwareKeyboardController.current + + val state by viewModel.state.collectAsStateWithLifecycle() + val isUpdatingBodyState by viewModel.isBodyUpdating.collectAsStateWithLifecycle() + + var recipientsOpen by rememberSaveable { mutableStateOf(false) } + var focusedField by rememberSaveable { + mutableStateOf(if (state.fields.to.isEmpty()) FocusedFieldType.TO else FocusedFieldType.BODY) + } + val snackbarHostState = remember { ProtonSnackbarHostState() } + val bottomSheetType = rememberSaveable { mutableStateOf(ComposerBottomSheetType.ChangeSender) } + val bottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val attachmentSizeDialogState = remember { mutableStateOf(false) } + val sendingErrorDialogState = remember { mutableStateOf(null) } + val senderChangedNoticeDialogState = remember { mutableStateOf(null) } + val sendWithoutSubjectDialogState = remember { mutableStateOf(false) } + val sendExpiringMessageDialogState = remember { + mutableStateOf(SendExpiringMessageDialogState(false, emptyList())) + } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents(), + onResult = { uris -> + viewModel.submit(ComposerAction.AttachmentsAdded(uris)) + } + ) + + val readContactsPermission = rememberPermissionState( + permission = Manifest.permission.READ_CONTACTS + ) + + val isShowReadContactsPermissionRationale = remember { mutableStateOf(false) } + if (shouldShowPermissionDialog(state, isShowReadContactsPermissionRationale)) { + ProtonAlertDialog( + title = stringResource(id = R.string.device_contacts_permission_dialog_title), + text = { ProtonAlertDialogText(R.string.device_contacts_permission_dialog_message) }, + dismissButton = { + ProtonAlertDialogButton(R.string.device_contacts_permission_dialog_action_button_deny) { + viewModel.submit(ComposerAction.DeviceContactsPromptDenied) + isShowReadContactsPermissionRationale.value = false + } + }, + confirmButton = { + ProtonAlertDialogButton(R.string.device_contacts_permission_dialog_action_button) { + isShowReadContactsPermissionRationale.value = false + if (readContactsPermission.status.shouldShowRationale) { + readContactsPermission.launchPermissionRequest() + } + } + }, + onDismissRequest = { isShowReadContactsPermissionRationale.value = false } + ) + } + + LaunchedEffect(readContactsPermission.status.isGranted) { + if (state.isDeviceContactsSuggestionsEnabled && !readContactsPermission.status.isGranted) { + if (readContactsPermission.status.shouldShowRationale) { + isShowReadContactsPermissionRationale.value = true + } else { + readContactsPermission.launchPermissionRequest() + } + } + } + + ConsumableLaunchedEffect(effect = state.openImagePicker) { + imagePicker.launch("*/*") + } + + ProtonModalBottomSheetLayout( + sheetContent = bottomSheetHeightConstrainedContent { + when (bottomSheetType.value) { + ComposerBottomSheetType.ChangeSender -> ChangeSenderBottomSheetContent( + state.senderAddresses, + { sender -> viewModel.submit(ComposerAction.SenderChanged(sender)) } + ) + + ComposerBottomSheetType.SetExpirationTime -> SetExpirationTimeBottomSheetContent( + expirationTime = state.messageExpiresIn, + onDoneClick = { viewModel.submit(ComposerAction.ExpirationTimeSet(it)) } + ) + } + }, + sheetState = bottomSheetState + ) { + Scaffold( + modifier = Modifier.testTag(ComposerTestTags.RootItem), + topBar = { + ComposerTopBar( + attachmentsCount = state.attachments.attachments.size, + onAddAttachmentsClick = { + viewModel.submit(ComposerAction.OnAddAttachments) + }, + onCloseComposerClick = { + viewModel.submit(ComposerAction.OnCloseComposer) + }, + onSendMessageComposerClick = { + viewModel.submit(ComposerAction.OnSendMessage) + }, + isSendMessageButtonEnabled = state.isSubmittable && !isUpdatingBodyState, + enableSecondaryButtonsInteraction = !isUpdatingBodyState + ) + }, + bottomBar = { + ComposerBottomBar( + draftId = state.fields.draftId, + senderEmail = SenderEmail(state.fields.sender.email), + isMessagePasswordSet = state.isMessagePasswordSet, + isMessageExpirationTimeSet = state.messageExpiresIn != Duration.ZERO, + onSetMessagePasswordClick = actions.onSetMessagePasswordClick, + onSetExpirationTimeClick = { + bottomSheetType.value = ComposerBottomSheetType.SetExpirationTime + viewModel.submit(ComposerAction.OnSetExpirationTimeRequested) + }, + enableInteractions = !isUpdatingBodyState + ) + }, + snackbarHost = { + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHost), + protonSnackbarHostState = snackbarHostState + ) + } + ) { paddingValues -> + if (state.isLoading) { + @Suppress("MagicNumber") + Surface( + modifier = Modifier + .fillMaxSize() + .alpha(.5f) + ) { ProtonCenteredProgress() } + } else { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(scrollState) + ) { + // Not showing the form till we're done loading ensure it does receive the + // right "initial values" from state when displayed + ComposerForm( + modifier = Modifier.testTag(ComposerTestTags.ComposerForm), + emailValidator = viewModel::validateEmailAddress, + recipientsOpen = recipientsOpen, + initialFocus = focusedField, + changeFocusToField = state.changeFocusToField, + fields = state.fields, + replaceDraftBody = state.replaceDraftBody, + shouldForceBodyTextFocus = state.focusTextBody, + actions = buildActions( + viewModel, + { recipientsOpen = it }, + { focusedField = it }, + { bottomSheetType.value = it } + ), + contactSuggestions = state.contactSuggestions, + areContactSuggestionsExpanded = state.areContactSuggestionsExpanded, + shouldRestrictWebViewHeight = state.shouldRestrictWebViewHeight + ) + if (state.attachments.attachments.isNotEmpty()) { + AttachmentFooter( + messageBodyAttachmentsUiModel = state.attachments, + actions = AttachmentFooter.Actions( + onShowAllAttachments = { Timber.d("On show all attachments clicked") }, + onAttachmentClicked = { Timber.d("On attachment clicked: $it") }, + onAttachmentDeleteClicked = { viewModel.submit(ComposerAction.RemoveAttachment(it)) } + ) + ) + } + } + } + } + } + + if (sendWithoutSubjectDialogState.value) { + + SendingWithEmptySubjectDialog( + onConfirmClicked = { + viewModel.submit(ComposerAction.ConfirmSendingWithoutSubject) + sendWithoutSubjectDialogState.value = false + }, + onDismissClicked = { + viewModel.submit(ComposerAction.RejectSendingWithoutSubject) + sendWithoutSubjectDialogState.value = false + } + ) + } + + if (sendExpiringMessageDialogState.value.isVisible) { + SendExpiringMessageDialog( + externalRecipients = sendExpiringMessageDialogState.value.externalParticipants, + onConfirmClicked = { + viewModel.submit(ComposerAction.SendExpiringMessageToExternalRecipientsConfirmed) + sendExpiringMessageDialogState.value = sendExpiringMessageDialogState.value.copy(isVisible = false) + }, + onDismissClicked = { + sendExpiringMessageDialogState.value = sendExpiringMessageDialogState.value.copy(isVisible = false) + } + ) + } + + if (attachmentSizeDialogState.value) { + ProtonAlertDialog( + onDismissRequest = { attachmentSizeDialogState.value = false }, + confirmButton = { + ProtonAlertDialogButton(R.string.composer_attachment_size_exceeded_dialog_confirm_button) { + attachmentSizeDialogState.value = false + } + }, + title = stringResource(id = R.string.composer_attachment_size_exceeded_dialog_title), + text = { + ProtonAlertDialogText( + stringResource( + id = R.string.composer_attachment_size_exceeded_dialog_message, + Formatter.formatShortFileSize( + LocalContext.current, + StoreAttachments.MAX_ATTACHMENTS_SIZE + ) + ) + ) + } + ) + } + + senderChangedNoticeDialogState.value?.run { + ProtonAlertDialog( + onDismissRequest = { senderChangedNoticeDialogState.value = null }, + confirmButton = { + ProtonAlertDialogButton(R.string.composer_sender_changed_dialog_confirm_button) { + senderChangedNoticeDialogState.value = null + } + }, + title = stringResource(id = R.string.composer_sender_changed_dialog_title), + text = { ProtonAlertDialogText(this) } + ) + } + + sendingErrorDialogState.value?.run { + SendingErrorDialog( + errorMessage = this, + onDismissClicked = { + sendingErrorDialogState.value = null + viewModel.clearSendingError() + } + ) + } + + ConsumableTextEffect(effect = state.recipientValidationError) { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + + ConsumableTextEffect(effect = state.premiumFeatureMessage) { message -> + snackbarHostState.showSnackbar(type = ProtonSnackbarType.NORM, message = message) + } + + ConsumableTextEffect(effect = state.error) { error -> + snackbarHostState.showSnackbar(type = ProtonSnackbarType.ERROR, message = error) + } + + ConsumableTextEffect(effect = state.warning) { warning -> + snackbarHostState.showSnackbar(type = ProtonSnackbarType.WARNING, message = warning) + } + + val errorAttachmentReEncryption = stringResource(id = R.string.composer_attachment_reencryption_failed_message) + ConsumableLaunchedEffect(effect = state.attachmentsReEncryptionFailed) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.ERROR, message = errorAttachmentReEncryption) + } + + ConsumableLaunchedEffect(effect = state.changeBottomSheetVisibility) { show -> + if (show) { + dismissKeyboard(context, view, keyboardController) + bottomSheetState.show() + } else { + bottomSheetState.hide() + } + } + + ConsumableLaunchedEffect(effect = state.closeComposer) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + } + + ConsumableLaunchedEffect(effect = state.closeComposerWithDraftSaved) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showDraftSavedSnackbar(state.fields.draftId) + } + + ConsumableLaunchedEffect(effect = state.closeComposerWithMessageSending) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showMessageSendingSnackbar() + } + + ConsumableLaunchedEffect(effect = state.closeComposerWithMessageSendingOffline) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showMessageSendingOfflineSnackbar() + } + + ConsumableTextEffect(effect = state.sendingErrorEffect) { + sendingErrorDialogState.value = it + } + + ConsumableTextEffect(effect = state.senderChangedNotice) { + senderChangedNoticeDialogState.value = it + } + + ConsumableLaunchedEffect(effect = state.attachmentsFileSizeExceeded) { attachmentSizeDialogState.value = true } + + ConsumableLaunchedEffect(effect = state.confirmSendingWithoutSubject) { + sendWithoutSubjectDialogState.value = true + } + + ConsumableLaunchedEffect(effect = state.confirmSendExpiringMessage) { + sendExpiringMessageDialogState.value = SendExpiringMessageDialogState( + isVisible = true, externalParticipants = it + ) + } + + BackHandler(true) { + viewModel.submit(ComposerAction.OnCloseComposer) + } +} + +@Composable +private fun shouldShowPermissionDialog( + state: ComposerDraftState, + isShowReadContactsPermissionRationale: MutableState +) = state.isDeviceContactsSuggestionsEnabled && + state.isDeviceContactsSuggestionsPromptEnabled && + isShowReadContactsPermissionRationale.value + +@Suppress("LongParameterList") +private fun buildActions( + viewModel: ComposerViewModel, + onToggleRecipients: (Boolean) -> Unit, + onFocusChanged: (FocusedFieldType) -> Unit, + setBottomSheetType: (ComposerBottomSheetType) -> Unit +): ComposerFormActions = ComposerFormActions( + onToggleRecipients = onToggleRecipients, + onFocusChanged = onFocusChanged, + onToChanged = { viewModel.submit(ComposerAction.RecipientsToChanged(it)) }, + onCcChanged = { viewModel.submit(ComposerAction.RecipientsCcChanged(it)) }, + onBccChanged = { viewModel.submit(ComposerAction.RecipientsBccChanged(it)) }, + onContactSuggestionsDismissed = { viewModel.submit(ComposerAction.ContactSuggestionsDismissed(it)) }, + onDeviceContactsPromptDenied = { viewModel.submit(ComposerAction.DeviceContactsPromptDenied) }, + onContactSuggestionTermChanged = { searchTerm, suggestionsField -> + viewModel.submit(ComposerAction.ContactSuggestionTermChanged(searchTerm, suggestionsField)) + }, + onSubjectChanged = { viewModel.submit(ComposerAction.SubjectChanged(Subject(it))) }, + onBodyChanged = { + viewModel.submit(ComposerAction.DraftBodyChanged(DraftBody(it))) + }, + onChangeSender = { + setBottomSheetType(ComposerBottomSheetType.ChangeSender) + viewModel.submit(ComposerAction.ChangeSenderRequested) + }, + onRespondInline = { viewModel.submit(ComposerAction.RespondInlineRequested) } +) + +@Deprecated("Part of Composer V1, keys to be ported to ComposerScreen2") +object ComposerScreen { + + const val DraftMessageIdKey = "draft_message_id" + const val SerializedDraftActionKey = "serialized_draft_action_key" + const val DraftActionForShareKey = "draft_action_for_share_key" + const val HasSavedDraftKey = "draft_action_for_saved_draft_key" + + @Deprecated("Part of Composer V1, to be ported to ComposerScreen2") + data class Actions( + val onCloseComposerClick: () -> Unit, + val onSetMessagePasswordClick: (MessageId, SenderEmail) -> Unit, + val showDraftSavedSnackbar: (MessageId) -> Unit, + val showMessageSendingSnackbar: () -> Unit, + val showMessageSendingOfflineSnackbar: () -> Unit + ) { + + companion object { + + val Empty = Actions( + + onCloseComposerClick = {}, + onSetMessagePasswordClick = { _, _ -> }, + showDraftSavedSnackbar = {}, + showMessageSendingSnackbar = {}, + showMessageSendingOfflineSnackbar = {} + ) + } + } +} + +@Composable +@AdaptivePreviews +private fun MessageDetailScreenPreview() { + ProtonTheme3 { + ComposerScreen(ComposerScreen.Actions.Empty) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen2.kt new file mode 100644 index 0000000000..ff72342d4f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerScreen2.kt @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import android.text.format.Formatter +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import ch.protonmail.android.mailcommon.presentation.ui.CommonTestTags +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerBottomSheetType +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.model.SendExpiringMessageDialogState +import ch.protonmail.android.mailcomposer.presentation.model.operations.ComposerAction2 +import ch.protonmail.android.mailcomposer.presentation.ui.form.ComposerForm2 +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2 +import ch.protonmail.android.mailmessage.presentation.ui.AttachmentFooter +import ch.protonmail.android.uicomponents.bottomsheet.bottomSheetHeightConstrainedContent +import ch.protonmail.android.uicomponents.dismissKeyboard +import ch.protonmail.android.uicomponents.snackbar.DismissableSnackbarHost +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.compose.component.ProtonModalBottomSheetLayout +import me.proton.core.compose.component.ProtonSnackbarHostState +import me.proton.core.compose.component.ProtonSnackbarType +import timber.log.Timber +import kotlin.time.Duration + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ComposerScreen2(actions: ComposerScreen.Actions) { + val recipientsStateManager = remember { RecipientsStateManager() } + + val viewModel = hiltViewModel { factory -> + factory.create(recipientsStateManager) + } + + val context = LocalContext.current + val view = LocalView.current + val keyboardController = LocalSoftwareKeyboardController.current + + val composerStates by viewModel.composerStates.collectAsStateWithLifecycle() + val mainState = composerStates.main + val attachmentsState = composerStates.attachments + val accessoriesState = composerStates.accessories + val effectsState = composerStates.effects + + var bottomSheetType = rememberSaveable { mutableStateOf(ComposerBottomSheetType.ChangeSender) } + val bottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + + var attachmentSizeDialogVisible by remember { mutableStateOf(false) } + var sendingErrorDialogState by remember { mutableStateOf(null) } + var senderChangedNoticeDialogState by remember { mutableStateOf(null) } + var sendWithoutSubjectDialogState by remember { mutableStateOf(false) } + var sendExpiringMessageDialogState by remember { + mutableStateOf(SendExpiringMessageDialogState(false, emptyList())) + } + + val formActions = ComposerForm2.Actions( + onChangeSender = { + bottomSheetType.value = ComposerBottomSheetType.ChangeSender + viewModel.submitAction(ComposerAction2.ChangeSender) + }, + onRespondInline = { viewModel.submitAction(ComposerAction2.RespondInline) } + ) + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents(), + onResult = { uris -> + if (uris.isNotEmpty()) viewModel.submitAction(ComposerAction2.StoreAttachments(uris)) + } + ) + + val snackbarHostState = remember { ProtonSnackbarHostState() } + + if (mainState.loadingType == ComposerState.LoadingType.Save) { + LoadingIndicator(preventBackNavigation = true) + } + + BackHandler(true) { + viewModel.submitAction(ComposerAction2.CloseComposer) + } + + ProtonModalBottomSheetLayout( + sheetContent = bottomSheetHeightConstrainedContent { + when (bottomSheetType.value) { + ComposerBottomSheetType.ChangeSender -> ChangeSenderBottomSheetContent( + mainState.senderAddresses, + { sender -> viewModel.submitAction(ComposerAction2.SetSenderAddress(sender)) } + ) + + ComposerBottomSheetType.SetExpirationTime -> SetExpirationTimeBottomSheetContent( + expirationTime = accessoriesState.messageExpiresIn, + onDoneClick = { viewModel.submitAction(ComposerAction2.SetMessageExpiration(it)) } + ) + } + }, + sheetState = bottomSheetState + ) { + Scaffold( + modifier = Modifier.testTag(ComposerTestTags.RootItem), + topBar = { + ComposerTopBar( + attachmentsCount = attachmentsState.uiModel.attachments.size, + onAddAttachmentsClick = { + viewModel.submitAction(ComposerAction2.OpenFilePicker) + }, + onCloseComposerClick = { + viewModel.submitAction(ComposerAction2.CloseComposer) + }, + onSendMessageComposerClick = { + viewModel.submitAction(ComposerAction2.SendMessage) + }, + isSendMessageButtonEnabled = mainState.isSubmittable, + enableSecondaryButtonsInteraction = true + ) + }, + bottomBar = { + ComposerBottomBar( + draftId = mainState.draftId, + senderEmail = SenderEmail(mainState.senderUiModel.email), + isMessagePasswordSet = accessoriesState.isMessagePasswordSet, + isMessageExpirationTimeSet = accessoriesState.messageExpiresIn != Duration.ZERO, + onSetMessagePasswordClick = actions.onSetMessagePasswordClick, + onSetExpirationTimeClick = { + bottomSheetType.value = ComposerBottomSheetType.SetExpirationTime + viewModel.submitAction(ComposerAction2.OpenExpirationSettings) + }, + enableInteractions = true + ) + }, + snackbarHost = { + DismissableSnackbarHost( + modifier = Modifier.testTag(CommonTestTags.SnackbarHost), + protonSnackbarHostState = snackbarHostState + ) + } + ) { paddingValues -> + if (mainState.loadingType == ComposerState.LoadingType.Initial) { + @Suppress("MagicNumber") + Surface( + modifier = Modifier + .fillMaxSize() + .alpha(.5f) + ) { ProtonCenteredProgress() } + } else { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(scrollState) + ) { + ComposerForm2( + changeFocusToField = effectsState.changeFocusToField, + senderEmail = mainState.senderUiModel.email, + recipientsStateManager = recipientsStateManager, + subjectTextField = viewModel.subjectTextField, + bodyTextField = viewModel.bodyFieldText, + quotedHtmlContent = mainState.quotedHtmlContent?.styled, + shouldRestrictWebViewHeight = mainState.shouldRestrictWebViewHeight, + focusTextBody = effectsState.focusTextBody, + actions = formActions, + modifier = Modifier.testTag(ComposerTestTags.ComposerForm) + ) + + if (attachmentsState.uiModel.attachments.isNotEmpty()) { + AttachmentFooter( + messageBodyAttachmentsUiModel = attachmentsState.uiModel, + actions = AttachmentFooter.Actions( + onShowAllAttachments = { Timber.d("On show all attachments clicked") }, + onAttachmentClicked = { Timber.d("On attachment clicked: $it") }, + onAttachmentDeleteClicked = { + viewModel.submitAction(ComposerAction2.RemoveAttachment(it)) + } + ) + ) + } + } + } + } + } + + ConsumableLaunchedEffect(effect = effectsState.openImagePicker) { + imagePicker.launch("*/*") + } + + ConsumableLaunchedEffect(effect = effectsState.closeComposer) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + } + + ConsumableLaunchedEffect(effect = effectsState.closeComposerWithDraftSaved) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showDraftSavedSnackbar(mainState.draftId) + } + + ConsumableLaunchedEffect(effect = effectsState.closeComposerWithMessageSending) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showMessageSendingSnackbar() + } + + ConsumableLaunchedEffect(effect = effectsState.closeComposerWithMessageSendingOffline) { + dismissKeyboard(context, view, keyboardController) + actions.onCloseComposerClick() + actions.showMessageSendingOfflineSnackbar() + } + + ConsumableTextEffect(effect = effectsState.attachmentsReEncryptionFailed) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.ERROR, message = it) + } + + ConsumableTextEffect(effect = effectsState.error) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.ERROR, message = it) + } + + ConsumableTextEffect(effect = effectsState.exitError) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.ERROR, message = it) + actions.onCloseComposerClick() + } + + ConsumableTextEffect(effect = effectsState.warning) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.WARNING, message = it) + } + + ConsumableTextEffect(effect = effectsState.premiumFeatureMessage) { + snackbarHostState.showSnackbar(type = ProtonSnackbarType.NORM, message = it) + } + + ConsumableLaunchedEffect(effect = effectsState.changeBottomSheetVisibility) { show -> + if (show) { + dismissKeyboard(context, view, keyboardController) + bottomSheetState.show() + } else { + bottomSheetState.hide() + } + } + + ConsumableLaunchedEffect(effect = effectsState.confirmSendingWithoutSubject) { + sendWithoutSubjectDialogState = true + } + + ConsumableLaunchedEffect(effect = effectsState.confirmSendExpiringMessage) { + sendExpiringMessageDialogState = SendExpiringMessageDialogState( + isVisible = true, externalParticipants = it + ) + } + + ConsumableLaunchedEffect(effect = effectsState.attachmentsFileSizeExceeded) { + attachmentSizeDialogVisible = true + } + + ConsumableTextEffect(effect = effectsState.sendingErrorEffect) { + sendingErrorDialogState = it + } + + ConsumableTextEffect(effect = effectsState.senderChangedNotice) { + senderChangedNoticeDialogState = it + } + + if (sendWithoutSubjectDialogState) { + SendingWithEmptySubjectDialog( + onConfirmClicked = { + viewModel.submitAction(ComposerAction2.ConfirmSendWithNoSubject) + sendWithoutSubjectDialogState = false + }, + onDismissClicked = { + viewModel.submitAction(ComposerAction2.CancelSendWithNoSubject) + sendWithoutSubjectDialogState = false + } + ) + } + + if (sendExpiringMessageDialogState.isVisible) { + SendExpiringMessageDialog( + externalRecipients = sendExpiringMessageDialogState.externalParticipants, + onConfirmClicked = { + viewModel.submitAction(ComposerAction2.ConfirmSendExpirationSetToExternal) + sendExpiringMessageDialogState = sendExpiringMessageDialogState.copy(isVisible = false) + }, + onDismissClicked = { + viewModel.submitAction(ComposerAction2.CancelSendExpirationSetToExternal) + sendExpiringMessageDialogState = sendExpiringMessageDialogState.copy(isVisible = false) + } + ) + } + + if (attachmentSizeDialogVisible) { + ProtonAlertDialog( + onDismissRequest = { attachmentSizeDialogVisible = false }, + confirmButton = { + ProtonAlertDialogButton(R.string.composer_attachment_size_exceeded_dialog_confirm_button) { + attachmentSizeDialogVisible = false + } + }, + title = stringResource(id = R.string.composer_attachment_size_exceeded_dialog_title), + text = { + ProtonAlertDialogText( + stringResource( + id = R.string.composer_attachment_size_exceeded_dialog_message, + Formatter.formatShortFileSize( + LocalContext.current, + StoreAttachments.MAX_ATTACHMENTS_SIZE + ) + ) + ) + } + ) + } + + senderChangedNoticeDialogState?.run { + ProtonAlertDialog( + onDismissRequest = { senderChangedNoticeDialogState = null }, + confirmButton = { + ProtonAlertDialogButton(R.string.composer_sender_changed_dialog_confirm_button) { + senderChangedNoticeDialogState = null + } + }, + title = stringResource(id = R.string.composer_sender_changed_dialog_title), + text = { ProtonAlertDialogText(this) } + ) + } + + sendingErrorDialogState?.run { + SendingErrorDialog( + errorMessage = this, + onDismissClicked = { + sendingErrorDialogState = null + viewModel.submitAction(ComposerAction2.ClearSendingError) + } + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTestTags.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTestTags.kt new file mode 100644 index 0000000000..02b89f1777 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTestTags.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +object ComposerTestTags { + + const val RootItem = "ComposerScreenRootItem" + const val TopAppBar = "ComposerTopAppBar" + const val ComposerForm = "ComposerForm" + const val FieldPrefix = "FieldPrefix" + const val FromSender = "FromField" + const val ToRecipient = "ToField" + const val ExpandCollapseArrow = "ExpandCollapseArrow" + const val CollapseExpandArrow = "CollapseExpandArrow" + const val CcRecipient = "CcField" + const val BccRecipient = "BccField" + const val Subject = "Subject" + const val SubjectPlaceholder = "SubjectPlaceholder" + const val MessageBody = "MessageBody" + const val MessageHtmlQuotedBody = "MessageHtmlQuotedBody" + const val MessageBodyPlaceholder = "MessageBodyPlaceholder" + const val AttachmentsButton = "AttachmentsButton" + const val CloseButton = "CloseButton" + const val SendButton = "SendButton" + const val ChangeSenderButton = "ChangeSenderButton" + const val SendWithEmptySubjectDialog = "SendWithEmptySubjectDialog" + const val SendWithEmptySubjectDialogConfirm = "SendWithEmptySubjectDialogConfirm" + const val SendWithEmptySubjectDialogDismiss = "SendWithEmptySubjectDialogDismiss" + +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTopBar.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTopBar.kt new file mode 100644 index 0000000000..7811272f58 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/ComposerTopBar.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.uicomponents.thenIf +import me.proton.core.compose.component.appbar.ProtonTopAppBar +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultSmallStrongInverted + +@Composable +@Suppress("UseComposableActions") +internal fun ComposerTopBar( + attachmentsCount: Int, + onAddAttachmentsClick: () -> Unit, + onCloseComposerClick: () -> Unit, + onSendMessageComposerClick: () -> Unit, + isSendMessageButtonEnabled: Boolean, + enableSecondaryButtonsInteraction: Boolean +) { + ProtonTopAppBar( + modifier = Modifier.testTag(ComposerTestTags.TopAppBar), + title = {}, + navigationIcon = { + EnabledStateIconButton( + icon = rememberVectorPainter(Icons.Filled.Close), + isEnabled = enableSecondaryButtonsInteraction, + contentDescription = stringResource(R.string.close_composer_content_description), + onClick = onCloseComposerClick, + modifier = Modifier.testTag(ComposerTestTags.CloseButton) + ) + }, + actions = { + AttachmentsButton( + attachmentsCount = attachmentsCount, + onClick = onAddAttachmentsClick, + isEnabled = enableSecondaryButtonsInteraction + ) + + EnabledStateIconButton( + icon = painterResource(id = R.drawable.ic_proton_paper_plane), + isEnabled = isSendMessageButtonEnabled, + contentDescription = stringResource(R.string.send_message_content_description), + onClick = onSendMessageComposerClick, + modifier = Modifier + .testTag(ComposerTestTags.SendButton) + .thenIf(!isSendMessageButtonEnabled) { semantics { disabled() } } + ) + } + ) +} + +@Composable +private fun AttachmentsButton( + attachmentsCount: Int, + onClick: () -> Unit, + isEnabled: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy((-ProtonDimens.SmallSpacing.value).dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (attachmentsCount > 0) { + AttachmentsNumber(attachmentsCount) + } + + EnabledStateIconButton( + icon = painterResource(id = R.drawable.ic_proton_paper_clip), + isEnabled = isEnabled, + contentDescription = stringResource(id = R.string.composer_add_attachments_content_description), + modifier = Modifier.testTag(ComposerTestTags.AttachmentsButton), + onClick = onClick + ) + } +} + +@Composable +private fun AttachmentsNumber(attachmentsCount: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(ProtonTheme.colors.interactionNorm, CircleShape) + .border(Dp.Hairline, ProtonTheme.colors.backgroundNorm, CircleShape) + .padding(vertical = ProtonDimens.ExtraSmallSpacing, horizontal = ProtonDimens.SmallSpacing), + contentAlignment = Alignment.Center + ) { + Text( + text = attachmentsCount.toString(), + style = ProtonTheme.typography.defaultSmallStrongInverted + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/Constants.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/Constants.kt new file mode 100644 index 0000000000..7e9a779ad9 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/Constants.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +internal object Composer { + + const val MessageBodyPortraitMinLines = 6 +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/EnabledStateIconButton.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/EnabledStateIconButton.kt new file mode 100644 index 0000000000..b4dabaf34d --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/EnabledStateIconButton.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import ch.protonmail.android.uicomponents.thenIf +import kotlinx.coroutines.launch +import me.proton.core.compose.theme.ProtonTheme + +@Composable +fun EnabledStateIconButton( + icon: Painter, + isEnabled: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val enabledColor = ProtonTheme.colors.iconNorm + val disabledColor = ProtonTheme.colors.iconDisabled + + val animatedColor = remember { Animatable(if (isEnabled) enabledColor else disabledColor) } + val scope = rememberCoroutineScope() + + LaunchedEffect(isEnabled) { + val targetColor = if (isEnabled) enabledColor else disabledColor + + // Animate to the target color without abrupt interruptions + if (animatedColor.targetValue != targetColor) { + scope.launch { + animatedColor.animateTo( + targetValue = targetColor, + animationSpec = tween(durationMillis = 500) + ) + } + } + } + + IconButton( + modifier = modifier + .thenIf(!isEnabled) { semantics { disabled() } }, + onClick = onClick, + enabled = isEnabled + ) { + Icon( + painter = icon, + tint = animatedColor.value, + contentDescription = contentDescription + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/LoadingIndicator.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/LoadingIndicator.kt new file mode 100644 index 0000000000..5c0ab3933f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/LoadingIndicator.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import me.proton.core.compose.component.ProtonCenteredProgress + +@Composable +internal fun LoadingIndicator(preventBackNavigation: Boolean = true) { + BackHandler(enabled = preventBackNavigation) { } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.25f)) + .zIndex(1f) + .pointerInput(Unit) { + detectTapGestures { } + detectDragGestures { _, _ -> } + } + ) { + ProtonCenteredProgress() + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PasswordInputField.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PasswordInputField.kt new file mode 100644 index 0000000000..41306ead6b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PasswordInputField.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.captionStrongNorm +import me.proton.core.compose.theme.captionWeak +import me.proton.core.compose.theme.defaultNorm + +@Composable +fun PasswordInputField( + @StringRes titleRes: Int, + @StringRes supportingTextRes: Int?, + value: String, + showTrailingIcon: Boolean, + isError: Boolean, + onValueChange: (String) -> Unit, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + val focusManager = LocalFocusManager.current + var showPassword by rememberSaveable { mutableStateOf(false) } + + PasswordInputFieldLabel(text = stringResource(id = titleRes), isError = isError) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { onFocusChanged(it.hasFocus) }, + value = value, + onValueChange = { onValueChange(it) }, + shape = RoundedCornerShape(ProtonDimens.LargeCornerRadius), + colors = getPasswordInputFieldColors(), + singleLine = true, + textStyle = ProtonTheme.typography.defaultNorm, + trailingIcon = { + if (showTrailingIcon) { + PasswordInputFieldTrailingButton( + showPassword = showPassword, + onClick = { showPassword = !showPassword } + ) + } + }, + isError = isError, + visualTransformation = if (showTrailingIcon && !showPassword) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }) + ) + + supportingTextRes?.let { PasswordInputFieldSupportingText(textId = it, isError = isError) } + } +} + +@Composable +fun PasswordInputFieldLabel( + text: String, + isError: Boolean, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier.padding(bottom = ProtonDimens.SmallSpacing), + text = text, + style = ProtonTheme.typography.captionStrongNorm, + color = if (isError) ProtonTheme.colors.notificationError else ProtonTheme.colors.textNorm + ) +} + +@Composable +fun PasswordInputFieldTrailingButton( + showPassword: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + val iconId = if (showPassword) R.drawable.ic_proton_eye_slash else R.drawable.ic_proton_eye + val contentDescriptionId = if (showPassword) { + R.string.set_message_password_button_hide + } else { + R.string.set_message_password_button_show + } + Icon( + painter = painterResource(id = iconId), + contentDescription = stringResource(id = contentDescriptionId), + tint = ProtonTheme.colors.iconHint + ) + } +} + +@Composable +fun PasswordInputFieldSupportingText( + textId: Int, + isError: Boolean, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier.padding(top = ProtonDimens.ExtraSmallSpacing), + text = stringResource(id = textId), + style = ProtonTheme.typography.captionWeak, + color = if (isError) ProtonTheme.colors.notificationError else ProtonTheme.colors.textWeak + ) +} + +@Composable +fun getPasswordInputFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = ProtonTheme.colors.textNorm, + unfocusedTextColor = ProtonTheme.colors.textNorm, + focusedContainerColor = ProtonTheme.colors.backgroundSecondary, + unfocusedContainerColor = ProtonTheme.colors.backgroundSecondary, + focusedBorderColor = ProtonTheme.colors.brandNorm, + unfocusedBorderColor = Color.Transparent, + errorBorderColor = ProtonTheme.colors.notificationError +) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PrefixedEmailSelector.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PrefixedEmailSelector.kt new file mode 100644 index 0000000000..1b1b62687f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/PrefixedEmailSelector.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.uicomponents.text.defaultTextFieldColors +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm + +@Composable +internal fun PrefixedEmailSelector( + @StringRes prefixStringResource: Int, + modifier: Modifier = Modifier, + selectedEmail: String, + onChangeSender: () -> Unit +) { + Row(modifier) { + TextField( + value = selectedEmail, + onValueChange = { }, + modifier = Modifier + .testTag(PrefixedEmailSelectorTestTags.TextField) + .align(Alignment.CenterVertically) + .weight(1f), + readOnly = true, + textStyle = ProtonTheme.typography.defaultNorm, + prefix = { + Row { + Text( + modifier = Modifier.testTag(ComposerTestTags.FieldPrefix), + text = stringResource(prefixStringResource), + color = ProtonTheme.colors.textWeak, + style = ProtonTheme.typography.defaultNorm + ) + Spacer(modifier = Modifier.size(ProtonDimens.ExtraSmallSpacing)) + } + }, + singleLine = true, + colors = TextFieldDefaults.defaultTextFieldColors(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ) + ) + + ChangeSenderButton(Modifier.align(Alignment.CenterVertically), onChangeSender) + } +} + +@Composable +private fun ChangeSenderButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + IconButton( + modifier = modifier + .testTag(ComposerTestTags.ChangeSenderButton), + onClick = onClick + ) { + Icon( + painter = painterResource(id = R.drawable.ic_proton_three_dots_vertical), + tint = ProtonTheme.colors.iconWeak, + contentDescription = NO_CONTENT_DESCRIPTION + ) + } +} + +object PrefixedEmailSelectorTestTags { + + const val TextField = "TextField" +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RecipientFields.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RecipientFields.kt new file mode 100644 index 0000000000..0495a52354 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RecipientFields.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import ch.protonmail.android.mailcommon.presentation.compose.FocusableFormScope +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import ch.protonmail.android.mailcommon.presentation.ui.MailDivider +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.uicomponents.chips.ChipsListField +import ch.protonmail.android.uicomponents.chips.ContactSuggestionState +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import ch.protonmail.android.uicomponents.composer.suggestions.ContactSuggestionItem +import ch.protonmail.android.uicomponents.thenIf +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import timber.log.Timber + +@Composable +@Deprecated("Part of Composer V1, to be replaced with RecipientFields2") +internal fun FocusableFormScope.RecipientFields( + modifier: Modifier = Modifier, + fields: ComposerFields, + fieldFocusRequesters: Map, + recipientsOpen: Boolean, + emailValidator: (String) -> Boolean, + contactSuggestions: Map>, + actions: ComposerFormActions, + areContactSuggestionsExpanded: Map +) { + val recipientsButtonRotation = remember { Animatable(0F) } + val isShowingToSuggestions = areContactSuggestionsExpanded[ContactSuggestionsField.TO] == true + val isShowingCcSuggestions = areContactSuggestionsExpanded[ContactSuggestionsField.CC] == true + val isShowingBccSuggestions = areContactSuggestionsExpanded[ContactSuggestionsField.BCC] == true + val hasCcBccContent = fields.cc.isNotEmpty() || fields.bcc.isNotEmpty() + val shouldShowCcBcc = recipientsOpen || hasCcBccContent + + Row( + modifier = modifier.fillMaxWidth() + ) { + ChipsListField( + label = stringResource(id = R.string.to_prefix), + value = fields.to.map { it.toChipItem() }, + chipValidator = emailValidator, + modifier = Modifier + .weight(1f) + .testTag(ComposerTestTags.ToRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.TO), + focusRequester = fieldFocusRequesters[FocusedFieldType.TO], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.TO) + }, + onSuggestionsDismissed = { + if (isShowingToSuggestions) actions.onContactSuggestionsDismissed(ContactSuggestionsField.TO) + }, + onListChanged = { + actions.onToChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() }) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.TO] ?: false, + contactSuggestionItems = contactSuggestions[ContactSuggestionsField.TO]?.map { + it.toSuggestionContactItem() + } ?: emptyList() + ), + chevronIconContent = { + if (!hasCcBccContent) { + IconButton( + modifier = Modifier + .align(Alignment.Top) + .focusProperties { canFocus = false }, + onClick = { actions.onToggleRecipients(!recipientsOpen) } + ) { + Icon( + modifier = Modifier + .thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Closed) { + testTag(ComposerTestTags.ExpandCollapseArrow) + } + .thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues.Open) { + testTag(ComposerTestTags.CollapseExpandArrow) + } + .rotate(recipientsButtonRotation.value) + .size(ProtonDimens.SmallIconSize), + imageVector = ImageVector.vectorResource( + id = me.proton.core.presentation.R.drawable.ic_proton_chevron_down_filled + ), + tint = ProtonTheme.colors.textWeak, + contentDescription = stringResource(id = R.string.composer_expand_recipients_button) + ) + } + } + } + ) + } + + if (shouldShowCcBcc && !isShowingToSuggestions) { + Column { + MailDivider() + ChipsListField( + label = stringResource(id = R.string.cc_prefix), + value = fields.cc.map { it.toChipItem() }, + chipValidator = emailValidator, + modifier = Modifier + .testTag(ComposerTestTags.CcRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.CC), + focusRequester = fieldFocusRequesters[FocusedFieldType.CC], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.CC) + }, + onSuggestionsDismissed = { + if (isShowingCcSuggestions) actions.onContactSuggestionsDismissed(ContactSuggestionsField.CC) + }, + onListChanged = { + actions.onCcChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() }) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.CC] ?: false, + contactSuggestionItems = contactSuggestions[ContactSuggestionsField.CC]?.map { + it.toSuggestionContactItem() + } ?: emptyList() + ) + ) + + if (!isShowingCcSuggestions) { + MailDivider() + ChipsListField( + label = stringResource(id = R.string.bcc_prefix), + value = fields.bcc.map { it.toChipItem() }, + chipValidator = emailValidator, + modifier = Modifier + .testTag(ComposerTestTags.BccRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.BCC), + focusRequester = fieldFocusRequesters[FocusedFieldType.BCC], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + actions.onContactSuggestionTermChanged(it, ContactSuggestionsField.BCC) + }, + onSuggestionsDismissed = { + if (isShowingBccSuggestions) + actions.onContactSuggestionsDismissed(ContactSuggestionsField.BCC) + }, + onListChanged = { + actions.onBccChanged(it.mapNotNull { chipItem -> chipItem.toRecipientUiModel() }) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = areContactSuggestionsExpanded[ContactSuggestionsField.BCC] ?: false, + contactSuggestionItems = contactSuggestions[ContactSuggestionsField.BCC]?.map { + it.toSuggestionContactItem() + } ?: emptyList() + ) + ) + } + } + } + + LaunchedEffect(key1 = recipientsOpen) { + recipientsButtonRotation.animateTo( + if (recipientsOpen) RecipientsButtonRotationValues.Open else RecipientsButtonRotationValues.Closed + ) + } +} + +private object RecipientsButtonRotationValues { + + const val Open = 180f + const val Closed = 0f +} + +private fun ChipItem.toRecipientUiModel(): RecipientUiModel? = when (this) { + is ChipItem.Counter -> null + is ChipItem.Invalid -> RecipientUiModel.Invalid(value) + is ChipItem.Valid -> RecipientUiModel.Valid(value) +} + +private fun RecipientUiModel.toChipItem(): ChipItem = when (this) { + is RecipientUiModel.Invalid -> ChipItem.Invalid(address) + is RecipientUiModel.Valid -> ChipItem.Valid(address) +} + +@Composable +private fun ContactSuggestionUiModel.toSuggestionContactItem(): ContactSuggestionItem = when (this) { + is ContactSuggestionUiModel.Contact -> ContactSuggestionItem.Contact( + initials = this.initial, + header = this.name, + subheader = this.email, + email = this.email + ) + + is ContactSuggestionUiModel.ContactGroup -> { + val backgroundColor = runCatching { Color(android.graphics.Color.parseColor(this.color)) }.getOrElse { + Timber.tag("getContactGroupColor").w("Failed to convert raw string color from $color") + ProtonTheme.colors.backgroundSecondary + } + + ContactSuggestionItem.Group( + header = this.name, + subheader = TextUiModel.PluralisedText( + value = R.plurals.composer_recipient_suggestion_contacts, + count = this.emails.size + ).string(), + emails = this.emails, + backgroundColor = backgroundColor + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RespondInlineButton.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RespondInlineButton.kt new file mode 100644 index 0000000000..5e5a2a565d --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/RespondInlineButton.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterEnd +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.component.ProtonTextButton +import me.proton.core.compose.theme.ProtonDimens + +@Composable +internal fun RespondInlineButton(onRespondInlineClick: () -> Unit, modifier: Modifier = Modifier) { + + Box( + modifier = modifier + .fillMaxWidth() + .padding(ProtonDimens.DefaultSpacing) + ) { + ProtonTextButton( + onClick = onRespondInlineClick, + modifier = Modifier.align(CenterEnd) + ) { + Text(text = stringResource(id = R.string.respondInline)) + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendExpiringMessageDialog.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendExpiringMessageDialog.kt new file mode 100644 index 0000000000..a919ae4bd5 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendExpiringMessageDialog.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailmessage.domain.model.Recipient +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultWeak + +@Composable +fun SendExpiringMessageDialog( + externalRecipients: List, + onConfirmClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier +) { + ProtonAlertDialog( + modifier = modifier, + titleResId = R.string.composer_send_expiring_message_to_external_recipients_dialog_title, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource( + id = R.string.composer_send_expiring_message_to_external_recipients_dialog_text + ), + style = ProtonTheme.typography.defaultWeak + ) + Spacer(modifier = Modifier.height(ProtonDimens.SmallSpacing)) + Text( + text = externalRecipients.joinToString(separator = "\n") { it.address }, + style = ProtonTheme.typography.defaultWeak + ) + } + }, + dismissButton = { + ProtonAlertDialogButton( + titleResId = R.string.composer_send_expiring_message_to_external_recipients_dialog_cancel + ) { onDismissClicked() } + }, + confirmButton = { + ProtonAlertDialogButton( + titleResId = R.string.composer_send_expiring_message_to_external_recipients_dialog_confirm + ) { onConfirmClicked() } + }, + onDismissRequest = {} + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingErrorDialog.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingErrorDialog.kt new file mode 100644 index 0000000000..88a5b6e3e9 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingErrorDialog.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.ProtonTheme3 +import me.proton.core.compose.theme.defaultWeak + +@Composable +fun SendingErrorDialog( + errorMessage: String, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier +) { + ProtonAlertDialog( + modifier = modifier, + titleResId = R.string.message_sending_error_dialog_header, + text = { + Column { + Text( + text = stringResource(id = R.string.message_sending_error_dialog_text_error), + style = ProtonTheme.typography.defaultWeak, + modifier = modifier + ) + Spacer(modifier = Modifier.height(ProtonDimens.DefaultSpacing)) + Text( + text = errorMessage, + style = ProtonTheme.typography.defaultWeak, + modifier = modifier + ) + } + }, + dismissButton = { + ProtonAlertDialogButton(R.string.message_sending_error_dialog_button_dismiss) { onDismissClicked() } + }, + confirmButton = { }, + onDismissRequest = { onDismissClicked() } + ) +} + +@Preview +@Composable +private fun SendingErrorDialogPreview() { + ProtonTheme3 { + ProtonTheme { + SendingErrorDialog( + "This is error message", + {} + ) + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingWithEmptySubjectDialog.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingWithEmptySubjectDialog.kt new file mode 100644 index 0000000000..21e2498921 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SendingWithEmptySubjectDialog.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.ProtonTheme3 +import me.proton.core.compose.theme.defaultWeak + +@Composable +fun SendingWithEmptySubjectDialog( + onConfirmClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier +) { + ProtonAlertDialog( + modifier = modifier.testTag(ComposerTestTags.SendWithEmptySubjectDialog), + titleResId = R.string.composer_send_without_subject_dialog_title, + text = { + Column { + Text( + text = stringResource(id = R.string.composer_send_without_subject_dialog_text), + style = ProtonTheme.typography.defaultWeak, + modifier = modifier + ) + } + }, + dismissButton = { + ProtonAlertDialogButton( + titleResId = R.string.composer_send_without_subject_dialog_reject_button, + modifier = Modifier.testTag(ComposerTestTags.SendWithEmptySubjectDialogDismiss) + ) { onDismissClicked() } + }, + confirmButton = { + ProtonAlertDialogButton( + titleResId = R.string.composer_send_without_subject_dialog_confirm_button, + modifier = Modifier.testTag(ComposerTestTags.SendWithEmptySubjectDialogConfirm) + ) { onConfirmClicked() } + }, + onDismissRequest = {} + ) +} + +@Preview +@Composable +private fun SendingWithEmptySubjectDialogPreview() { + ProtonTheme3 { + ProtonTheme { + SendingWithEmptySubjectDialog(onConfirmClicked = {}, onDismissClicked = {}) + } + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetExpirationTimeBottomSheetContent.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetExpirationTimeBottomSheetContent.kt new file mode 100644 index 0000000000..f8d948d7f2 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetExpirationTimeBottomSheetContent.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.ui.SetExpirationTimeBottomSheetContent.MaxDays +import ch.protonmail.android.mailcomposer.presentation.ui.SetExpirationTimeBottomSheetContent.MaxHours +import me.proton.core.compose.component.ProtonOutlinedTextField +import me.proton.core.compose.component.ProtonRawListItem +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultSmallStrongNorm +import me.proton.core.compose.theme.defaultStrongNorm +import me.proton.core.compose.theme.defaultStrongUnspecified +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +@Composable +fun SetExpirationTimeBottomSheetContent(expirationTime: Duration, onDoneClick: (Duration) -> Unit) { + + val selectedItem = remember { mutableStateOf(ExpirationTime.from(expirationTime)) } + val selectedCustomDays = remember { + mutableStateOf( + if (selectedItem.value == ExpirationTime.Custom) { + expirationTime.inWholeDays + } else 0 + ) + } + val selectedCustomHours = remember { + mutableStateOf( + if (selectedItem.value == ExpirationTime.Custom) { + expirationTime.inWholeHours - expirationTime.inWholeDays.days.inWholeHours + } else 0 + ) + } + + Column { + Row( + modifier = Modifier + .padding(ProtonDimens.DefaultSpacing) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.composer_expiration_time_bottom_sheet_title), + style = ProtonTheme.typography.defaultStrongNorm + ) + Text( + modifier = Modifier.clickable(role = Role.Button) { + val valueToBeSaved = if (selectedItem.value == ExpirationTime.Custom) { + (selectedCustomDays.value * 24 + selectedCustomHours.value).hours + } else selectedItem.value.duration + onDoneClick(valueToBeSaved) + }, + text = stringResource(id = R.string.composer_expiration_time_bottom_sheet_done), + style = ProtonTheme.typography.defaultStrongUnspecified, + color = ProtonTheme.colors.interactionNorm + ) + } + + Divider() + + LazyColumn { + items(items = ExpirationTime.values()) { item -> + ProtonRawListItem( + modifier = Modifier + .selectable(selected = item == selectedItem.value) { selectedItem.value = item } + .height(ProtonDimens.ListItemHeight) + .padding(horizontal = ProtonDimens.DefaultSpacing) + ) { + val textRes = when (item) { + ExpirationTime.None -> R.string.composer_bottom_bar_expiration_time_none + ExpirationTime.OneHour -> R.string.composer_bottom_bar_expiration_time_one_hour + ExpirationTime.OneDay -> R.string.composer_bottom_bar_expiration_time_one_day + ExpirationTime.ThreeDays -> R.string.composer_bottom_bar_expiration_time_three_days + ExpirationTime.OneWeek -> R.string.composer_bottom_bar_expiration_time_one_week + ExpirationTime.Custom -> R.string.composer_bottom_bar_expiration_time_custom + } + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = textRes), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (item == selectedItem.value) { + Icon( + painter = painterResource(id = R.drawable.ic_proton_checkmark), + contentDescription = NO_CONTENT_DESCRIPTION, + tint = ProtonTheme.colors.interactionNorm + ) + } + } + } + if (selectedItem.value == ExpirationTime.Custom) { + item { + Row( + modifier = Modifier.padding( + horizontal = ProtonDimens.DefaultSpacing, + vertical = ProtonDimens.SmallSpacing + ) + ) { + ExpirationTimeDropdownMenu( + modifier = Modifier.weight(1f), + initialValue = TextFieldValue(selectedCustomDays.value.toString()), + type = ExpirationTimeDropdownMenuType.Days, + onSelectionChanged = { selectedCustomDays.value = it.toLong() } + ) + Spacer(modifier = Modifier.width(ProtonDimens.DefaultSpacing)) + ExpirationTimeDropdownMenu( + modifier = Modifier.weight(1f), + initialValue = TextFieldValue(selectedCustomHours.value.toString()), + type = ExpirationTimeDropdownMenuType.Hours, + onSelectionChanged = { selectedCustomHours.value = it.toLong() } + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ExpirationTimeDropdownMenu( + initialValue: TextFieldValue, + type: ExpirationTimeDropdownMenuType, + modifier: Modifier = Modifier, + onSelectionChanged: (Int) -> Unit +) { + val expanded = remember { mutableStateOf(false) } + + val label = type.name + val maxValue = if (type == ExpirationTimeDropdownMenuType.Days) MaxDays else MaxHours + + Column(modifier = modifier) { + Text( + text = label, + style = ProtonTheme.typography.defaultSmallStrongNorm + ) + Spacer(modifier = Modifier.height(ProtonDimens.SmallSpacing)) + ExposedDropdownMenuBox( + expanded = expanded.value, + onExpandedChange = { expanded.value = !expanded.value } + ) { + ProtonOutlinedTextField( + value = initialValue, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) } + ) + ExposedDropdownMenu( + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + for (value in 0..maxValue) { + DropdownMenuItem( + text = { Text(text = value.toString()) }, + onClick = { + onSelectionChanged(value) + expanded.value = false + } + ) + } + } + } + } +} + +object SetExpirationTimeBottomSheetContent { + + const val MaxDays = 28 + const val MaxHours = 23 +} + +enum class ExpirationTimeDropdownMenuType { Days, Hours } + +enum class ExpirationTime(val duration: Duration) { + None(Duration.ZERO), + OneHour(1.hours), + OneDay(1.days), + ThreeDays(3.days), + OneWeek(7.days), + Custom(Duration.INFINITE); + + companion object { + + fun from(duration: Duration) = values().find { it.duration == duration } ?: Custom + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetMessagePasswordScreen.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetMessagePasswordScreen.kt new file mode 100644 index 0000000000..8ff2b5198e --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SetMessagePasswordScreen.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.NO_CONTENT_DESCRIPTION +import ch.protonmail.android.mailcommon.presentation.compose.HyperlinkText +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.MessagePasswordOperation +import ch.protonmail.android.mailcomposer.presentation.model.SetMessagePasswordState +import ch.protonmail.android.mailcomposer.presentation.viewmodel.SetMessagePasswordViewModel +import ch.protonmail.android.mailmessage.domain.model.MessageId +import kotlinx.serialization.Serializable +import me.proton.core.compose.component.ProtonCenteredProgress +import me.proton.core.compose.component.ProtonOutlinedButton +import me.proton.core.compose.component.ProtonSolidButton +import me.proton.core.compose.component.appbar.ProtonTopAppBar +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultInverted +import me.proton.core.compose.theme.defaultSmallUnspecified +import me.proton.core.compose.theme.defaultSmallWeak +import me.proton.core.compose.theme.defaultStrongNorm +import me.proton.core.compose.theme.defaultUnspecified + +@Composable +fun SetMessagePasswordScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SetMessagePasswordViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + modifier = modifier, + topBar = { + ProtonTopAppBar( + modifier = Modifier.fillMaxWidth(), + title = { + Text( + text = stringResource(id = R.string.set_message_password_title), + style = ProtonTheme.typography.defaultStrongNorm + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.presentation_back), + tint = ProtonTheme.colors.iconNorm + ) + } + } + ) + } + ) { paddingValues -> + when (state) { + is SetMessagePasswordState.Loading -> ProtonCenteredProgress() + is SetMessagePasswordState.Data -> { + SetMessagePasswordContent( + modifier = Modifier.padding(paddingValues), + state = state as SetMessagePasswordState.Data, + actions = SetMessagePasswordContent.Actions( + validatePassword = { password -> + viewModel.submit(MessagePasswordOperation.Action.ValidatePassword(password)) + }, + validateRepeatedPassword = { password, repeatedPassword -> + viewModel.submit( + MessagePasswordOperation.Action.ValidateRepeatedPassword(password, repeatedPassword) + ) + }, + onApplyButtonClick = { messagePassword, messagePasswordHint -> + viewModel.submit( + if ((state as SetMessagePasswordState.Data).isInEditMode) { + MessagePasswordOperation.Action.UpdatePassword(messagePassword, messagePasswordHint) + } else { + MessagePasswordOperation.Action.ApplyPassword(messagePassword, messagePasswordHint) + } + ) + }, + onRemoveButtonClick = { viewModel.submit(MessagePasswordOperation.Action.RemovePassword) }, + onBackClick = onBackClick + ) + ) + } + } + } +} + +@Composable +@Suppress("ComplexMethod") +private fun SetMessagePasswordContent( + state: SetMessagePasswordState.Data, + actions: SetMessagePasswordContent.Actions, + modifier: Modifier = Modifier +) { + ConsumableLaunchedEffect(effect = state.exitScreen) { + actions.onBackClick() + } + + Column( + modifier = modifier + .fillMaxSize() + .background(ProtonTheme.colors.backgroundNorm) + .verticalScroll(rememberScrollState(), reverseScrolling = true) + .padding(ProtonDimens.DefaultSpacing) + ) { + var messagePassword by rememberSaveable { mutableStateOf(state.initialMessagePasswordValue) } + var repeatedMessagePassword by rememberSaveable { mutableStateOf(state.initialMessagePasswordValue) } + var messagePasswordHint by rememberSaveable { mutableStateOf(state.initialMessagePasswordHintValue) } + var isMessagePasswordFieldActivated by rememberSaveable { mutableStateOf(state.isInEditMode) } + var isRepeatedMessagePasswordFieldActivated by rememberSaveable { mutableStateOf(state.isInEditMode) } + + fun validateMessagePassword() { + actions.validatePassword(messagePassword) + } + fun validateRepeatedMessagePassword() { + actions.validateRepeatedPassword(messagePassword, repeatedMessagePassword) + } + fun shouldApplyButtonBeEnabled() = isMessagePasswordFieldActivated && isRepeatedMessagePasswordFieldActivated && + !state.hasMessagePasswordError && !state.hasRepeatedMessagePasswordError + + MessagePasswordInfo() + MessagePasswordSpacer() + PasswordInputField( + titleRes = R.string.set_message_password_label, + supportingTextRes = if (state.hasMessagePasswordError) { + R.string.set_message_password_supporting_error_text + } else { + R.string.set_message_password_supporting_text + }, + value = messagePassword, + showTrailingIcon = true, + isError = state.hasMessagePasswordError, + onValueChange = { + messagePassword = it + validateMessagePassword() + if (isRepeatedMessagePasswordFieldActivated) validateRepeatedMessagePassword() + }, + onFocusChanged = { hasFocus -> + if (hasFocus) isMessagePasswordFieldActivated = true + if (isMessagePasswordFieldActivated) validateMessagePassword() + if (isRepeatedMessagePasswordFieldActivated) validateRepeatedMessagePassword() + } + ) + MessagePasswordSpacer() + PasswordInputField( + titleRes = R.string.set_message_password_label_repeat, + supportingTextRes = if (state.hasRepeatedMessagePasswordError) { + R.string.set_message_password_supporting_error_text_repeat + } else { + R.string.set_message_password_supporting_text_repeat + }, + value = repeatedMessagePassword, + showTrailingIcon = true, + isError = state.hasRepeatedMessagePasswordError, + onValueChange = { + repeatedMessagePassword = it + validateRepeatedMessagePassword() + if (isMessagePasswordFieldActivated) validateMessagePassword() + }, + onFocusChanged = { hasFocus -> + if (hasFocus) isRepeatedMessagePasswordFieldActivated = true + if (isRepeatedMessagePasswordFieldActivated) validateRepeatedMessagePassword() + if (isMessagePasswordFieldActivated) validateMessagePassword() + } + ) + MessagePasswordSpacer() + PasswordInputField( + titleRes = R.string.set_message_password_label_hint, + supportingTextRes = null, + value = messagePasswordHint, + showTrailingIcon = false, + isError = false, + onValueChange = { messagePasswordHint = it }, + onFocusChanged = {} + ) + MessagePasswordSpacer(height = ProtonDimens.LargerSpacing) + MessagePasswordButtons( + shouldShowEditingButtons = state.isInEditMode, + isApplyButtonEnabled = shouldApplyButtonBeEnabled(), + onApplyButtonClick = { actions.onApplyButtonClick(messagePassword, messagePasswordHint) }, + onRemoveButtonClick = actions.onRemoveButtonClick + ) + } +} + +@Composable +private fun MessagePasswordInfo(modifier: Modifier = Modifier) { + Row(modifier = modifier) { + Icon( + painter = painterResource(id = R.drawable.ic_proton_info_circle), + contentDescription = NO_CONTENT_DESCRIPTION, + tint = ProtonTheme.colors.iconWeak + ) + Spacer(modifier = Modifier.width(ProtonDimens.DefaultSpacing)) + Column { + Text( + text = stringResource(id = R.string.set_message_password_info_message), + style = ProtonTheme.typography.defaultSmallWeak + ) + HyperlinkText( + textResource = R.string.set_message_password_info_link, + textStyle = ProtonTheme.typography.defaultSmallUnspecified, + linkTextColor = ProtonTheme.colors.interactionNorm + ) + } + } +} + +@Composable +private fun MessagePasswordButtons( + shouldShowEditingButtons: Boolean, + isApplyButtonEnabled: Boolean, + onApplyButtonClick: () -> Unit, + onRemoveButtonClick: () -> Unit +) { + ProtonSolidButton( + modifier = Modifier + .fillMaxWidth() + .height(ProtonDimens.DefaultButtonMinHeight), + enabled = isApplyButtonEnabled, + onClick = onApplyButtonClick + ) { + Text( + text = stringResource( + id = if (shouldShowEditingButtons) { + R.string.set_message_password_button_save_changes + } else R.string.set_message_password_button_apply + ), + style = ProtonTheme.typography.defaultInverted + ) + } + if (shouldShowEditingButtons) { + MessagePasswordSpacer(height = ProtonDimens.DefaultSpacing) + ProtonOutlinedButton( + modifier = Modifier + .fillMaxWidth() + .height(ProtonDimens.DefaultButtonMinHeight), + onClick = onRemoveButtonClick + ) { + Text( + text = stringResource(id = R.string.set_message_password_button_remove_password), + style = ProtonTheme.typography.defaultUnspecified, + color = ProtonTheme.colors.interactionNorm + ) + } + } +} + +@Composable +private fun MessagePasswordSpacer(modifier: Modifier = Modifier, height: Dp = ProtonDimens.MediumSpacing) { + Spacer(modifier = modifier.height(height)) +} + +object SetMessagePasswordScreen { + + const val InputParamsKey = "InputParams" + + @Serializable + data class InputParams( + val messageId: MessageId, + val senderEmail: SenderEmail + ) +} + +object SetMessagePasswordContent { + data class Actions( + val validatePassword: (String) -> Unit, + val validateRepeatedPassword: (String, String) -> Unit, + val onApplyButtonClick: (String, String?) -> Unit, + val onRemoveButtonClick: () -> Unit, + val onBackClick: () -> Unit + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField.kt new file mode 100644 index 0000000000..5f6c457659 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import ch.protonmail.android.mailcomposer.presentation.R +import kotlinx.coroutines.flow.collectLatest +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm + +@Composable +@Deprecated("Part of Composer V1, to be replaced with SubjectTextField2") +internal fun SubjectTextField( + initialValue: String, + onSubjectChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val textFieldState = rememberTextFieldState(initialValue) + + val keyboardOptions = remember { + KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Next) + } + + var userUpdated by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text } + .collectLatest { + if (it.toString() != initialValue) userUpdated = true + + // This skips the onSubjectChange call when the SubjectTextField enters the composition + // without the user making any change to prevent an invalid draft creation. + if (userUpdated) onSubjectChange(it.toString()) + } + } + + BasicTextField( + modifier = modifier, + state = textFieldState, + textStyle = ProtonTheme.typography.defaultNorm, + cursorBrush = SolidColor(TextFieldDefaults.colors().cursorColor), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = keyboardOptions, + decorator = @Composable { innerTextField -> + if (textFieldState.text.isEmpty()) { + PlaceholderText() + } + innerTextField() + } + ) +} + +@Composable +private fun PlaceholderText() { + Text( + modifier = Modifier.testTag(ComposerTestTags.SubjectPlaceholder), + text = stringResource(R.string.subject_placeholder), + color = ProtonTheme.colors.textHint, + style = ProtonTheme.typography.defaultNorm + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField2.kt new file mode 100644 index 0000000000..348938607f --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/SubjectTextField2.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui + +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import ch.protonmail.android.mailcomposer.presentation.R +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm + +@Composable +internal fun SubjectTextField2(textFieldState: TextFieldState, modifier: Modifier = Modifier) { + BasicTextField( + modifier = modifier, + state = textFieldState, + textStyle = ProtonTheme.typography.defaultNorm, + cursorBrush = SolidColor(TextFieldDefaults.colors().cursorColor), + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + decorator = @Composable { innerTextField -> + if (textFieldState.text.isEmpty()) { + PlaceholderText() + } + innerTextField() + } + ) +} + +@Composable +private fun PlaceholderText() { + Text( + modifier = Modifier.testTag(ComposerTestTags.SubjectPlaceholder), + text = stringResource(R.string.subject_placeholder), + color = ProtonTheme.colors.textHint, + style = ProtonTheme.typography.defaultNorm + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/chips/ComposerChipsListField.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/chips/ComposerChipsListField.kt new file mode 100644 index 0000000000..8568a0d06b --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/chips/ComposerChipsListField.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui.chips + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.delete +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.ConsumableTextEffect +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerChipsListViewModel +import ch.protonmail.android.uicomponents.chips.ChipsListField +import ch.protonmail.android.uicomponents.chips.ChipsListTextField +import ch.protonmail.android.uicomponents.chips.ChipsTestTags +import ch.protonmail.android.uicomponents.chips.ContactSuggestionState +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import ch.protonmail.android.uicomponents.composer.suggestions.ContactSuggestionItemElement +import ch.protonmail.android.uicomponents.thenIf +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import me.proton.core.compose.theme.defaultNorm +import me.proton.core.presentation.utils.showToast + +@Composable +fun ComposerChipsListField( + label: String, + chipsList: List, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, + focusOnClick: Boolean = true, + actions: ChipsListField.Actions, + contactSuggestionState: ContactSuggestionState, + chevronIconContent: @Composable () -> Unit = {} +) { + // Every chip field needs its specific VM instance, as it can't be shared. + // We use the `label` String as key, as it's stable and won't change throughout the whole lifecycle. + val composerChipsListViewModel = hiltViewModel(key = label) + val context = LocalContext.current + + val state by composerChipsListViewModel.state.collectAsStateWithLifecycle() + val listState = state.listState + val textFieldState = composerChipsListViewModel.textFieldState + + LaunchedEffect(chipsList) { + listState.updateItems(chipsList) + } + + val interactionSource = remember { MutableInteractionSource() } + val chipsListActions = remember { + ChipsListTextField.Actions( + onFocusChanged = { focusChange -> + listState.setFocusState(focusChange.isFocused) + if (!focusChange.hasFocus) { + listState.createChip() + textFieldState.edit { delete(0, length) } + actions.onSuggestionsDismissed() + } + }, + onItemDeleted = { + it?.let { listState.onDelete(it) } ?: listState.onDelete() + }, + onTriggerChipCreation = { + listState.createChip() + textFieldState.edit { delete(0, length) } + actions.onSuggestionsDismissed() + } + ) + } + + BackHandler(contactSuggestionState.areSuggestionsExpanded) { + actions.onSuggestionsDismissed() + } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = label, + modifier = Modifier + .testTag(ChipsTestTags.FieldPrefix) + .align(Alignment.Top) + .padding(vertical = ProtonDimens.DefaultSpacing) + .padding(start = ProtonDimens.DefaultSpacing), + color = ProtonTheme.colors.textWeak, + style = ProtonTheme.typography.defaultNorm + ) + + ChipsListTextField( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .thenIf(focusOnClick) { + clickable( + interactionSource = interactionSource, + indication = null, + onClick = { focusRequester?.requestFocus() } + ) + }, + textFieldState = textFieldState, + state = listState, + focusRequester = focusRequester, + actions = chipsListActions + ) + + chevronIconContent() + } + + if (contactSuggestionState.areSuggestionsExpanded && + contactSuggestionState.contactSuggestionItems.isNotEmpty() + ) { + Divider(modifier = Modifier.padding(bottom = ProtonDimens.DefaultSpacing)) + + contactSuggestionState.contactSuggestionItems.forEach { selectionOption -> + ContactSuggestionItemElement(textFieldState.text.toString(), selectionOption, onClick = { + actions.onSuggestionsDismissed() + listState.typeWord(it) + textFieldState.edit { delete(0, length) } + }) + } + } + } + + ConsumableLaunchedEffect(state.listChanged) { + actions.onListChanged(it) + } + + ConsumableLaunchedEffect(state.suggestionsTermTyped) { + actions.onSuggestionTermTyped(it) + } + + ConsumableTextEffect(state.duplicateRemovalWarning) { + context.showToast(it) + } + + ConsumableTextEffect(state.invalidEntryWarning) { + context.showToast(it) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/ComposerForm2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/ComposerForm2.kt new file mode 100644 index 0000000000..06373f37e8 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/ComposerForm2.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui.form + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.hilt.navigation.compose.hiltViewModel +import ch.protonmail.android.mailcommon.presentation.ConsumableLaunchedEffect +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.compose.FocusableForm +import ch.protonmail.android.mailcommon.presentation.ui.MailDivider +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.ui.BodyHtmlQuote +import ch.protonmail.android.mailcomposer.presentation.ui.BodyTextField2 +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.mailcomposer.presentation.ui.PrefixedEmailSelector +import ch.protonmail.android.mailcomposer.presentation.ui.RespondInlineButton +import ch.protonmail.android.mailcomposer.presentation.ui.SubjectTextField2 +import ch.protonmail.android.mailcomposer.presentation.viewmodel.RecipientsViewModel +import ch.protonmail.android.uicomponents.keyboardVisibilityAsState +import me.proton.core.compose.theme.ProtonDimens +import timber.log.Timber + +@Composable +internal fun ComposerForm2( + changeFocusToField: Effect, + senderEmail: String, + recipientsStateManager: RecipientsStateManager, + subjectTextField: TextFieldState, + bodyTextField: TextFieldState, + quotedHtmlContent: StyledHtmlQuote?, + shouldRestrictWebViewHeight: Boolean, + focusTextBody: Effect, + actions: ComposerForm2.Actions, + modifier: Modifier = Modifier +) { + + val recipientsViewModel = hiltViewModel { factory -> + factory.create(recipientsStateManager) + } + + val isKeyboardVisible by keyboardVisibilityAsState() + val keyboardController = LocalSoftwareKeyboardController.current + val maxWidthModifier = Modifier.fillMaxWidth() + + var showSubjectAndBody by remember { mutableStateOf(true) } + + FocusableForm( + fieldList = listOf( + FocusedFieldType.TO, + FocusedFieldType.CC, + FocusedFieldType.BCC, + FocusedFieldType.SUBJECT, + FocusedFieldType.BODY + ), + initialFocus = FocusedFieldType.TO, + onFocusedField = { + Timber.d("Focus changed: onFocusedField: $it") + } + ) { fieldFocusRequesters -> + + ConsumableLaunchedEffect(effect = changeFocusToField) { + fieldFocusRequesters[it]?.requestFocus() + if (!isKeyboardVisible) { + keyboardController?.show() + } + } + + Column( + modifier = modifier.fillMaxWidth() + ) { + PrefixedEmailSelector( + prefixStringResource = R.string.from_prefix, + modifier = maxWidthModifier.testTag(ComposerTestTags.FromSender), + selectedEmail = senderEmail, + onChangeSender = actions.onChangeSender + ) + MailDivider() + + RecipientFields2( + fieldFocusRequesters = fieldFocusRequesters, + onToggleSuggestions = { isShown -> showSubjectAndBody = isShown }, + viewModel = recipientsViewModel + ) + + if (showSubjectAndBody) { + MailDivider() + SubjectTextField2( + textFieldState = subjectTextField, + modifier = maxWidthModifier + .padding(ProtonDimens.DefaultSpacing) + .testTag(ComposerTestTags.Subject) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.SUBJECT) + ) + MailDivider() + + BodyTextField2( + textFieldState = bodyTextField, + shouldRequestFocus = focusTextBody, + modifier = maxWidthModifier + .testTag(ComposerTestTags.MessageBody) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.BODY) + ) + + if (quotedHtmlContent != null) { + RespondInlineButton(actions.onRespondInline) + BodyHtmlQuote( + value = quotedHtmlContent.value, + modifier = maxWidthModifier.testTag(ComposerTestTags.MessageHtmlQuotedBody), + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight + ) + } + } + } + } +} + +internal object ComposerForm2 { + data class Actions( + val onChangeSender: () -> Unit, + val onRespondInline: () -> Unit + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/EmailValidator.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/EmailValidator.kt new file mode 100644 index 0000000000..4bb067dcfa --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/EmailValidator.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui.form + +internal object EmailValidator { + + // Taken from core, apparently valid for RFC 5322. Does not impose maximum length restrictions, we will + // rely in the API to give us an error in that case. + @Suppress("MaxLineLength") + private const val EMAIL_VALIDATION_PATTERN = + """(?:[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#${'$'}%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""" + + fun isValidEmail(emailAddress: String): Boolean { + val regex = EMAIL_VALIDATION_PATTERN.toRegex(RegexOption.IGNORE_CASE) + return emailAddress.isNotBlank() && regex.matches(emailAddress) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/RecipientFields2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/RecipientFields2.kt new file mode 100644 index 0000000000..21d6e0092a --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/ui/form/RecipientFields2.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.ui.form + +import android.Manifest +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ch.protonmail.android.mailcommon.presentation.compose.FocusableFormScope +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.model.string +import ch.protonmail.android.mailcommon.presentation.ui.MailDivider +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.toImmutableChipList +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerTestTags +import ch.protonmail.android.mailcomposer.presentation.ui.chips.ComposerChipsListField +import ch.protonmail.android.mailcomposer.presentation.viewmodel.RecipientsViewModel +import ch.protonmail.android.uicomponents.chips.ChipsListField +import ch.protonmail.android.uicomponents.chips.ContactSuggestionState +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import ch.protonmail.android.uicomponents.composer.suggestions.ContactSuggestionItem +import ch.protonmail.android.uicomponents.thenIf +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import me.proton.core.compose.component.ProtonAlertDialog +import me.proton.core.compose.component.ProtonAlertDialogButton +import me.proton.core.compose.component.ProtonAlertDialogText +import me.proton.core.compose.theme.ProtonDimens +import me.proton.core.compose.theme.ProtonTheme +import timber.log.Timber + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +internal fun FocusableFormScope.RecipientFields2( + modifier: Modifier = Modifier, + fieldFocusRequesters: Map, + onToggleSuggestions: (Boolean) -> Unit, + viewModel: RecipientsViewModel +) { + var recipientsOpen by remember { mutableStateOf(false) } + val recipientsButtonRotation = remember { Animatable(0F) } + val recipients by viewModel.recipientsStateManager.recipients.collectAsStateWithLifecycle() + val recipientsTo = recipients.toRecipients.toImmutableChipList() + val recipientsCcValue = recipients.ccRecipients.toImmutableChipList() + val recipientsBccValue = recipients.bccRecipients.toImmutableChipList() + + val suggestions by viewModel.contactsSuggestions.collectAsStateWithLifecycle() + val suggestionField by viewModel.contactSuggestionsFieldFlow.collectAsStateWithLifecycle() + val isShowingToSuggestions = suggestionField == ContactSuggestionsField.TO && suggestions.isNotEmpty() + val isShowingCcSuggestions = suggestionField == ContactSuggestionsField.CC && suggestions.isNotEmpty() + val isShowingBccSuggestions = suggestionField == ContactSuggestionsField.BCC && suggestions.isNotEmpty() + val hasCcBccContent = recipientsCcValue.isNotEmpty() || recipientsBccValue.isNotEmpty() + val shouldShowCcBcc = recipientsOpen || hasCcBccContent + + var showContactsPermissionDialog by remember { mutableStateOf(false) } + val contactsPermissionDenied = viewModel.contactsPermissionDenied.collectAsStateWithLifecycle(false) + + val readContactsPermission = rememberPermissionState( + permission = Manifest.permission.READ_CONTACTS + ) + + if (!readContactsPermission.status.isGranted && + !contactsPermissionDenied.value && + showContactsPermissionDialog + ) { + ProtonAlertDialog( + title = stringResource(id = R.string.device_contacts_permission_dialog_title), + text = { ProtonAlertDialogText(R.string.device_contacts_permission_dialog_message) }, + dismissButton = { + ProtonAlertDialogButton(R.string.device_contacts_permission_dialog_action_button_deny) { + showContactsPermissionDialog = false + viewModel.denyContactsPermission() + } + }, + confirmButton = { + ProtonAlertDialogButton(R.string.device_contacts_permission_dialog_action_button) { + showContactsPermissionDialog = false + readContactsPermission.launchPermissionRequest() + } + }, + onDismissRequest = { + showContactsPermissionDialog = false + viewModel.denyContactsPermission() + } + ) + } + + LaunchedEffect(suggestions, suggestionField) { + onToggleSuggestions(suggestionField == null || suggestions.isEmpty()) + } + + LaunchedEffect(readContactsPermission.status.isGranted) { + if (!readContactsPermission.status.isGranted && !contactsPermissionDenied.value) { + if (readContactsPermission.status.shouldShowRationale) { + showContactsPermissionDialog = true + } else if (!showContactsPermissionDialog) { + readContactsPermission.launchPermissionRequest() + } + } + } + + LaunchedEffect(suggestions, suggestionField) { + onToggleSuggestions(suggestionField == null || suggestions.isEmpty()) + } + + LaunchedEffect(readContactsPermission.status.isGranted) { + if (!readContactsPermission.status.isGranted) { + if (readContactsPermission.status.shouldShowRationale) { + showContactsPermissionDialog = true + } else { + readContactsPermission.launchPermissionRequest() + } + } + } + + Row( + modifier = modifier.fillMaxWidth() + ) { + ComposerChipsListField( + label = stringResource(id = R.string.to_prefix), + chipsList = recipientsTo, + modifier = Modifier + .weight(1f) + .testTag(ComposerTestTags.ToRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.TO), + focusRequester = fieldFocusRequesters[FocusedFieldType.TO], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + viewModel.updateSearchTerm(it, ContactSuggestionsField.TO) + }, + onSuggestionsDismissed = { + if (isShowingToSuggestions) viewModel.closeSuggestions() + }, + onListChanged = { + viewModel.updateRecipients(it.toUiModel(), ContactSuggestionsField.TO) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = isShowingToSuggestions, + contactSuggestionItems = suggestions.map { it.toSuggestionContactItem2() } + ), + chevronIconContent = { + if (!hasCcBccContent) { + IconButton( + modifier = Modifier + .align(Alignment.Top) + .focusProperties { canFocus = false }, + onClick = { + recipientsOpen = !recipientsOpen + viewModel.closeSuggestions() + } + ) { + Icon( + modifier = Modifier + .thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues2.Closed) { + testTag(ComposerTestTags.ExpandCollapseArrow) + } + .thenIf(recipientsButtonRotation.value == RecipientsButtonRotationValues2.Open) { + testTag(ComposerTestTags.CollapseExpandArrow) + } + .rotate(recipientsButtonRotation.value) + .size(ProtonDimens.SmallIconSize), + imageVector = ImageVector.vectorResource( + id = me.proton.core.presentation.R.drawable.ic_proton_chevron_down_filled + ), + tint = ProtonTheme.colors.textWeak, + contentDescription = stringResource(id = R.string.composer_expand_recipients_button) + ) + } + } + } + ) + } + + AnimatedVisibility( + visible = shouldShowCcBcc && !isShowingToSuggestions, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + Column { + MailDivider() + ComposerChipsListField( + label = stringResource(id = R.string.cc_prefix), + chipsList = recipientsCcValue, + modifier = Modifier + .testTag(ComposerTestTags.CcRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.CC), + focusRequester = fieldFocusRequesters[FocusedFieldType.CC], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + viewModel.updateSearchTerm(it, ContactSuggestionsField.CC) + }, + onSuggestionsDismissed = { + if (isShowingCcSuggestions) viewModel.closeSuggestions() + }, + onListChanged = { + viewModel.updateRecipients(it.toUiModel(), ContactSuggestionsField.CC) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = isShowingCcSuggestions, + contactSuggestionItems = suggestions.map { it.toSuggestionContactItem2() } + ) + ) + + if (!isShowingCcSuggestions) { + MailDivider() + ComposerChipsListField( + label = stringResource(id = R.string.bcc_prefix), + chipsList = recipientsBccValue, + modifier = Modifier + .testTag(ComposerTestTags.BccRecipient) + .retainFieldFocusOnConfigurationChange(FocusedFieldType.BCC), + focusRequester = fieldFocusRequesters[FocusedFieldType.BCC], + actions = ChipsListField.Actions( + onSuggestionTermTyped = { + viewModel.updateSearchTerm(it, ContactSuggestionsField.BCC) + }, + onSuggestionsDismissed = { + if (isShowingBccSuggestions) viewModel.closeSuggestions() + }, + onListChanged = { + viewModel.updateRecipients(it.toUiModel(), ContactSuggestionsField.BCC) + } + ), + contactSuggestionState = ContactSuggestionState( + areSuggestionsExpanded = isShowingBccSuggestions, + contactSuggestionItems = suggestions.map { it.toSuggestionContactItem2() } + ) + ) + } + } + } + + LaunchedEffect(key1 = recipientsOpen) { + recipientsButtonRotation.animateTo( + if (recipientsOpen) RecipientsButtonRotationValues2.Open else RecipientsButtonRotationValues2.Closed + ) + } +} + +// Move the below it once ComposerV2 becomes the default flow. +private object RecipientsButtonRotationValues2 { + + const val Open = 180f + const val Closed = 0f +} + +private fun List.toUiModel() = mapNotNull { it -> + when (it) { + is ChipItem.Counter -> null + is ChipItem.Invalid -> RecipientUiModel.Invalid(it.value) + is ChipItem.Valid -> RecipientUiModel.Valid(it.value) + } +} + +@Composable +private fun ContactSuggestionUiModel.toSuggestionContactItem2(): ContactSuggestionItem = when (this) { + is ContactSuggestionUiModel.Contact -> ContactSuggestionItem.Contact( + initials = this.initial, + header = this.name, + subheader = this.email, + email = this.email + ) + + is ContactSuggestionUiModel.ContactGroup -> { + val backgroundColor = runCatching { Color(android.graphics.Color.parseColor(this.color)) }.getOrElse { + Timber.tag("getContactGroupColor").w("Failed to convert raw string color from $color") + ProtonTheme.colors.backgroundSecondary + } + + ContactSuggestionItem.Group( + header = this.name, + subheader = TextUiModel.PluralisedText( + value = R.plurals.composer_recipient_suggestion_contacts, + count = this.emails.size + ).string(), + emails = this.emails, + backgroundColor = backgroundColor + ) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainText.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainText.kt new file mode 100644 index 0000000000..7a2863b7f9 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ConvertHtmlToPlainText.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import androidx.core.text.HtmlCompat +import javax.inject.Inject + +class ConvertHtmlToPlainText @Inject constructor() { + + operator fun invoke(html: String): String { + val htmlToConvert = html + .removeHead() + .removeStyle() + return HtmlCompat.fromHtml(htmlToConvert, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + } + + private fun String.removeStyle(): String { + if (this.hasNoStyleTag()) { + return this + } + val styleTag = if (this.contains(STYLE_START_TAG)) STYLE_START_TAG else STYLE_OPEN_START_TAG + val beforeStyle = this.substringBefore(styleTag) + val afterStyle = this.substringAfter(STYLE_END_TAG) + return beforeStyle + afterStyle + } + + private fun String.removeHead(): String { + if (this.hasNoHeadTag()) { + return this + } + val beforeHead = this.substringBefore(HEAD_START_TAG) + val afterHead = this.substringAfter(HEAD_END_TAG) + return beforeHead + afterHead + } + + private fun String.hasNoHeadTag() = this.contains(HEAD_START_TAG).not() || this.contains(HEAD_END_TAG).not() + + private fun String.hasNoStyleTag() = + this.contains(STYLE_START_TAG).not() && this.contains(STYLE_OPEN_START_TAG).not() || + this.contains(STYLE_END_TAG).not() + + private companion object { + + const val HEAD_START_TAG = "" + const val HEAD_END_TAG = "" + const val STYLE_START_TAG = "" + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/FormatMessageSendingError.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/FormatMessageSendingError.kt new file mode 100644 index 0000000000..73c2f1ae81 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/FormatMessageSendingError.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import android.content.Context +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailmessage.domain.model.SendingError +import dagger.hilt.android.qualifiers.ApplicationContext +import me.proton.core.util.kotlin.takeIfNotEmpty +import javax.inject.Inject + +class FormatMessageSendingError @Inject constructor( + @ApplicationContext private val context: Context +) { + + operator fun invoke(sendingError: SendingError): String? { + + val formattedRecipients = when (sendingError) { + is SendingError.SendPreferences -> { + sendingError.errors.toList().mapNotNull { (email, error) -> + when (error) { + SendingError.SendPreferencesError.TrustedKeysInvalid -> { + context.getString(R.string.message_sending_error_dialog_text_reason_no_trusted_keys) + .format( + email + ) + } + + SendingError.SendPreferencesError.AddressDisabled -> { + context.getString(R.string.message_sending_error_dialog_text_reason_address_disabled) + .format( + email + ) + } + + else -> null + } + } + } + + SendingError.Other -> emptyList() + SendingError.MessageAlreadySent -> emptyList() + } + + return formattedRecipients.takeIfNotEmpty()?.joinToString(separator = "\n\n") + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignature.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignature.kt new file mode 100644 index 0000000000..7a436dd449 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignature.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailsettings.domain.model.Signature +import ch.protonmail.android.mailsettings.domain.model.SignatureValue +import ch.protonmail.android.mailsettings.domain.usecase.identity.GetAddressSignature +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.model.toPlainText +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.usecase.GetMobileFooter +import me.proton.core.domain.entity.UserId +import timber.log.Timber +import javax.inject.Inject + +class InjectAddressSignature @Inject constructor( + private val getAddressSignature: GetAddressSignature, + private val getMobileFooter: GetMobileFooter +) { + + suspend operator fun invoke( + userId: UserId, + draftBody: DraftBody, + senderEmail: SenderEmail, + previousSenderEmail: SenderEmail? = null + ): Either = either { + + val addressSignature = getAddressSignature(userId, senderEmail.value).getOrElse { + Timber.e("InjectAddressSignature: error getting address signature: $it") + Signature(enabled = false, SignatureValue("")) + } + + val mobileFooter = getMobileFooter(userId).bind() + + previousSenderEmail?.let { senderEmail -> + getAddressSignature(userId, senderEmail.value).fold( + ifLeft = { Timber.e("Error getting previous address signature: $senderEmail") }, + ifRight = { previousAddressSignature -> + getBodyWithReplacedSignature( + draftBody, + previousAddressSignature.value, + mobileFooter.value, + addressSignature + ).let { + return it.right() + } + } + ) + } + + val draftBodyWithAddressSignature = StringBuilder().apply { + append(draftBody.value) + + if (addressSignature.enabled && addressSignature.value.toPlainText().isNotBlank()) { + append(SignatureFooterSeparator) + append(addressSignature.value.toPlainText()) + } + + if (mobileFooter.enabled && mobileFooter.value.isNotBlank()) { + append(SignatureFooterSeparator) + append(mobileFooter.value) + } + }.let { DraftBody(it.toString()) } + + return@either draftBodyWithAddressSignature + } + + private fun getBodyWithReplacedSignature( + draftBody: DraftBody, + previousAddressSignature: SignatureValue, + existingMobileFooter: String, + addressSignature: Signature + ): DraftBody { + val previousSignatureIndex = previousAddressSignature.toPlainText() + .takeIf { it.isNotEmpty() } + ?.let { signature -> + draftBody.value.lastIndexOf(signature).takeIf { it != -1 } + } + + val bodyStringBuilder = StringBuilder(draftBody.value) + val signatureReplacement = StringBuilder().apply { + if (addressSignature.enabled) append(addressSignature.value.toPlainText()) else append("") + } + + // If it has a signature. + previousSignatureIndex?.let { lastIndex -> + bodyStringBuilder.replace( + lastIndex, + previousAddressSignature.toPlainText().length + lastIndex, + signatureReplacement.toString() + ) + return DraftBody(bodyStringBuilder.toString()) + } + + // Footer needs not to be empty, in that case we add a separator to the signature replacement. + val footerIndex = if (existingMobileFooter.isNotEmpty()) { + draftBody.value.indexOf(existingMobileFooter).takeIf { it != -1 } + } else { + null + }?.also { signatureReplacement.append(SignatureFooterSeparator) } + + // If it has no signature but a footer. + footerIndex?.let { lastIndex -> + bodyStringBuilder.replace( + lastIndex, + lastIndex, + signatureReplacement.toString() + ) + return DraftBody(bodyStringBuilder.toString()) + } + + // If it has nothing, add some spacing but only if the signature replacement is NOT blank. + if (!draftBody.value.startsWith(SignatureFooterSeparator) && signatureReplacement.isNotBlank()) { + bodyStringBuilder.append(SignatureFooterSeparator) + } + + bodyStringBuilder.append(signatureReplacement.toString()) + return DraftBody(bodyStringBuilder.toString()) + } + + private companion object { + + const val SignatureFooterSeparator = "\n\n" + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFields.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFields.kt new file mode 100644 index 0000000000..57c23d7349 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFields.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import android.content.Context +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.usecase.FormatExtendedTime +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailmessage.domain.model.DecryptedMessageBody +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.Message +import ch.protonmail.android.mailmessage.domain.model.MessageWithBody +import ch.protonmail.android.mailmessage.domain.model.MimeType +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailsettings.domain.model.MobileFooter +import ch.protonmail.android.mailsettings.domain.model.Signature +import ch.protonmail.android.mailsettings.domain.model.SignatureValue +import ch.protonmail.android.mailsettings.domain.usecase.identity.GetAddressSignature +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.model.toPlainText +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.usecase.GetMobileFooter +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.domain.entity.UserId +import me.proton.core.user.domain.entity.UserAddress +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +class ParentMessageToDraftFields @Inject constructor( + @ApplicationContext private val context: Context, + private val observeUserAddresses: ObserveUserAddresses, + private val formatExtendedTime: FormatExtendedTime, + private val getAddressSignature: GetAddressSignature, + private val getMobileFooter: GetMobileFooter, + private val subjectWithPrefixForAction: SubjectWithPrefixForAction +) { + + suspend operator fun invoke( + userId: UserId, + messageWithDecryptedBody: MessageWithDecryptedBody, + action: DraftAction + ): Either { + val message = messageWithDecryptedBody.messageWithBody.message + val decryptedBody = messageWithDecryptedBody.decryptedMessageBody + val userAddresses = observeUserAddresses(userId).firstOrNull() ?: return DataError.Local.NoDataCached.left() + val sender = getSenderEmail(userAddresses, message) + val senderAddressSignature = + getAddressSignature(userId, sender.value).getOrElse { Signature(enabled = false, SignatureValue("")) } + val mobileFooter = getMobileFooter(userId).getOrNull() ?: return DataError.Local.Unknown.left() + + return DraftFields( + sender, + Subject(subjectWithPrefixForAction(action, message.subject)), + buildQuotedPlainTextBody(message, decryptedBody, senderAddressSignature, mobileFooter), + RecipientsTo(recipientsForAction(action, messageWithDecryptedBody.messageWithBody)), + RecipientsCc(ccRecipientsForAction(action, message, sender)), + RecipientsBcc(bccRecipientsForAction(action, message)), + buildQuotedHtmlBody(message, decryptedBody) + ).right() + } + + private fun buildQuotedPlainTextBody( + message: Message, + decryptedBody: DecryptedMessageBody, + senderAddressSignature: Signature, + mobileFooter: MobileFooter + ): DraftBody { + val senderSignatureEnabled = senderAddressSignature.enabled + val senderSignatureValue = senderAddressSignature.value.toPlainText() + val mobileFooterEnabled = mobileFooter.enabled + val mobileFooterValue = mobileFooter.value + + if (decryptedBody.mimeType != MimeType.PlainText) { + // HTML quote is fully created elsewhere, but we still need to inject signature + // and mobile footer into editable body + StringBuilder().apply { + if (senderSignatureEnabled && senderSignatureValue.isNotBlank()) { + append(SignatureFooterSeparator) + append(senderSignatureValue) + } + + if (mobileFooterEnabled && mobileFooterValue.isNotBlank()) { + append(SignatureFooterSeparator) + append(mobileFooterValue) + } + }.let { return DraftBody(it.toString()) } + } + + val bodyQuoted = decryptedBody.value + .split("\n") + .joinToString(separator = PlainTextNewLine) { "$PlainTextQuotePrefix $it" } + + val raw = StringBuilder().apply { + if (senderSignatureEnabled && senderSignatureValue.isNotBlank()) { + append(SignatureFooterSeparator) + append(senderSignatureValue) + } + + if (mobileFooterEnabled && mobileFooterValue.isNotBlank()) { + append(SignatureFooterSeparator) + append(mobileFooterValue) + } + + append(PlainTextNewLine) + append(PlainTextNewLine) + append(PlainTextNewLine) + append(buildOriginalMessageQuote()) + append(PlainTextNewLine) + append(buildSenderQuote(message)) + append(PlainTextNewLine) + append(PlainTextNewLine) + append(bodyQuoted) + } + return DraftBody(raw.toString()) + } + + private fun buildQuotedHtmlBody(message: Message, decryptedBody: DecryptedMessageBody): OriginalHtmlQuote? { + if (decryptedBody.mimeType == MimeType.PlainText) { + return null + } + + val raw = StringBuilder() + .append(ProtonMailQuote) + .append(LineBreak) + .append(LineBreak) + .append(buildOriginalMessageQuote()) + .append(LineBreak) + .append(buildSenderQuote(message)) + .append(LineBreak) + .append(ProtonMailBlockquote) + .append(decryptedBody.value) + .append(CloseProtonMailBlockquote) + .append(CloseProtonMailQuote) + .toString() + return OriginalHtmlQuote(raw) + } + + private fun buildOriginalMessageQuote() = + "-------- ${context.getString(R.string.composer_original_message_quote)} --------" + + private fun buildSenderQuote(message: Message): String { + val formattedTime = formatExtendedTime(message.time.seconds) as? TextUiModel.Text + return context.getString(R.string.composer_sender_quote).format( + formattedTime?.value, message.sender.name, message.sender.address + ) + } + + private fun getSenderEmail(addresses: List, message: Message): SenderEmail { + val address = addresses.firstOrNull { it.addressId == message.addressId }?.email + ?: addresses.minBy { it.order }.email + return SenderEmail(address) + } + + private fun recipientsForAction(action: DraftAction, messageWithBody: MessageWithBody): List { + val allRecipients = when (action) { + is DraftAction.PrefillForShare, + is DraftAction.Compose, + is DraftAction.ComposeToAddresses, // will be handled via VM + is DraftAction.Forward -> emptyList() + + is DraftAction.Reply -> if (messageWithBody.message.isSent()) { + messageWithBody.message.toList + } else { + listOf(messageWithBody.messageBody.replyTo) + } + + is DraftAction.ReplyAll -> if (messageWithBody.message.isSent()) { + messageWithBody.message.toList + } else { + listOf(messageWithBody.messageBody.replyTo) + } + } + return allRecipients + } + + private fun ccRecipientsForAction( + action: DraftAction, + message: Message, + senderEmail: SenderEmail + ) = when (action) { + is DraftAction.PrefillForShare, + is DraftAction.Compose, + is DraftAction.ComposeToAddresses, + is DraftAction.Forward, + is DraftAction.Reply -> emptyList() + + is DraftAction.ReplyAll -> if (message.isSent()) { + message.ccList + } else { + (message.toList + message.ccList).filter { it.address != senderEmail.value } + } + } + + private fun bccRecipientsForAction(action: DraftAction, message: Message) = when (action) { + is DraftAction.PrefillForShare, + is DraftAction.Compose, + is DraftAction.ComposeToAddresses, + is DraftAction.Forward, + is DraftAction.Reply -> emptyList() + + is DraftAction.ReplyAll -> if (message.isSent()) message.bccList else emptyList() + } + + companion object { + + const val ProtonMailQuote = "
" + const val ProtonMailBlockquote = "
" + const val CloseProtonMailQuote = "
" + const val CloseProtonMailBlockquote = "" + const val LineBreak = "
" + const val PlainTextNewLine = "\n" + const val PlainTextQuotePrefix = "> " + const val SignatureFooterSeparator = "\n\n" + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestions.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestions.kt new file mode 100644 index 0000000000..eef8e16634 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestions.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcommon.presentation.usecase.GetInitials +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcontact.domain.model.ContactGroup +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.util.kotlin.takeIfNotBlank +import javax.inject.Inject + +class SortContactsForSuggestions @Inject constructor( + private val getInitials: GetInitials, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { + + suspend operator fun invoke( + contacts: List, + deviceContacts: List, + contactGroups: List, + maxContactAutocompletionCount: Int + ): List = withContext(dispatcher) { + // Use a temporary map to store unique contacts based on their email address. + val temporaryEmailContactMap = mutableMapOf() + + val fromContacts = contacts.asSequence().flatMap { contact -> + contact.contactEmails.map { contactEmail -> + Triple(contact, contactEmail, Long.MAX_VALUE - contactEmail.lastUsedTime) + } + }.sortedBy { (contact, contactEmail, lastUsedTimeDescending) -> + "$lastUsedTimeDescending ${contact.name} ${contactEmail.email}" + }.mapNotNull { (contact, contactEmail, _) -> + val email = contactEmail.email + if (email in temporaryEmailContactMap) return@mapNotNull null + + ContactSuggestionUiModel.Contact( + name = contactEmail.name.takeIfNotBlank() + ?: contact.name.takeIfNotBlank() + ?: email, + initial = getInitials(contact.name).takeIfNotBlank() ?: "?", + email = email + ).also { temporaryEmailContactMap[email] = it } + } + + val fromDeviceContacts = deviceContacts.asSequence().mapNotNull { deviceContact -> + val email = deviceContact.email + if (email in temporaryEmailContactMap) return@mapNotNull null + + ContactSuggestionUiModel.Contact( + name = deviceContact.name, + initial = getInitials(deviceContact.name).takeIfNotBlank() ?: "?", + email = email + ).also { temporaryEmailContactMap[email] = it } + } + + val fromContactGroups = contactGroups.asSequence().map { contactGroup -> + ContactSuggestionUiModel.ContactGroup( + name = contactGroup.name, + emails = contactGroup.members.map { it.email }.distinct(), + color = contactGroup.color + ) + } + + val fromDeviceAndContactGroups = (fromDeviceContacts + fromContactGroups).sortedBy { it.name } + + return@withContext (fromContacts + fromDeviceAndContactGroups) + .take(maxContactAutocompletionCount) + .toList() + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/StyleQuotedHtml.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/StyleQuotedHtml.kt new file mode 100644 index 0000000000..9b41cc51e9 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/StyleQuotedHtml.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailmessage.presentation.model.MessageBodyWithType +import ch.protonmail.android.mailmessage.presentation.model.MimeTypeUiModel +import ch.protonmail.android.mailmessage.presentation.usecase.InjectCssIntoDecryptedMessageBody +import ch.protonmail.android.mailmessage.presentation.usecase.SanitizeHtmlOfDecryptedMessageBody +import javax.inject.Inject + +class StyleQuotedHtml @Inject constructor( + private val injectCssIntoDecryptedMessageBody: InjectCssIntoDecryptedMessageBody, + private val sanitizeHtmlOfDecryptedMessageBody: SanitizeHtmlOfDecryptedMessageBody +) { + + operator fun invoke(originalHtmlQuote: OriginalHtmlQuote): StyledHtmlQuote { + val originalHtmlMessageQuoteWithType = MessageBodyWithType(originalHtmlQuote.value, MimeTypeUiModel.Html) + val sanitizedQuoteRaw = sanitizeHtmlOfDecryptedMessageBody(originalHtmlMessageQuoteWithType) + + val sanitizedHtmlMessageWithType = MessageBodyWithType(sanitizedQuoteRaw, MimeTypeUiModel.Html) + val styledQuoteRaw = injectCssIntoDecryptedMessageBody(sanitizedHtmlMessageWithType) + + return StyledHtmlQuote(styledQuoteRaw) + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForAction.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForAction.kt new file mode 100644 index 0000000000..68d81693fd --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForAction.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import javax.inject.Inject + +class SubjectWithPrefixForAction @Inject constructor() { + + suspend operator fun invoke(action: DraftAction, subject: String): String = when (action) { + is DraftAction.Compose, + is DraftAction.PrefillForShare, + is DraftAction.ComposeToAddresses -> subject + + is DraftAction.Forward -> if (subject.trimStart() + .startsWith(FORWARD_PREFIX) + ) subject else "$FORWARD_PREFIX $subject" + + is DraftAction.Reply, + is DraftAction.ReplyAll -> if (subject.trimStart() + .startsWith(REPLY_PREFIX) + ) subject else "$REPLY_PREFIX $subject" + } + + companion object { + const val FORWARD_PREFIX = "Fw:" + const val REPLY_PREFIX = "Re:" + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModel.kt new file mode 100644 index 0000000000..4edcf4762a --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModel.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.delete +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerChipsFieldState +import ch.protonmail.android.mailcomposer.presentation.ui.form.EmailValidator +import ch.protonmail.android.uicomponents.chips.ChipsListState +import ch.protonmail.android.uicomponents.chips.ChipsListState.Companion.ChipsCreationRegex +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ComposerChipsListViewModel @Inject constructor() : ViewModel() { + + internal val textFieldState = TextFieldState() + + private val mutableState = MutableStateFlow(initialChipsListState()) + internal val state = mutableState.asStateFlow() + + init { + viewModelScope.launch { observe() } + } + + private suspend fun observe() = snapshotFlow { textFieldState.text } + .collectLatest { + // This is not ideal, but it's due to how the existing Chips state works. + mutableState.value.listState.type(it.toString()) + + if (ChipsCreationRegex.containsMatchIn(textFieldState.text)) textFieldState.edit { delete(0, length) } + mutableState.update { it.copy(suggestionsTermTyped = Effect.of(textFieldState.text.toString())) } + } + + private fun onListChanged(list: List) { + val deduplicatedList = list.distinct() + val duplicateRemovalWarning = if (deduplicatedList.size != list.size) { + Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + } else { + Effect.empty() + } + + mutableState.update { + it.copy( + duplicateRemovalWarning = duplicateRemovalWarning, + listChanged = Effect.of(deduplicatedList) + ) + } + mutableState.value.listState.updateItems(deduplicatedList) + } + + private fun onInvalidItem() { + mutableState.update { + it.copy(invalidEntryWarning = Effect.of(TextUiModel.TextRes(R.string.composer_error_invalid_email))) + } + } + + private fun initialChipsListState() = ComposerChipsFieldState( + ChipsListState( + isValid = { it: String -> EmailValidator.isValidEmail(it) }, + this::onListChanged, + this::onInvalidItem + ) + ) +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel.kt new file mode 100644 index 0000000000..6b4f80b596 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel.kt @@ -0,0 +1,875 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.getOrElse +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailcommon.domain.model.decode +import ch.protonmail.android.mailcommon.domain.model.hasEmailData +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.ClearMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAllAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAttachment +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploader +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses.Error +import ch.protonmail.android.mailcomposer.domain.usecase.GetDecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.GetExternalRecipients +import ch.protonmail.android.mailcomposer.domain.usecase.GetLocalMessageDecrypted +import ch.protonmail.android.mailcomposer.domain.usecase.IsValidEmailAddress +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.ProvideNewDraftId +import ch.protonmail.android.mailcomposer.domain.usecase.ReEncryptAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.SendMessage +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAllFields +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithBody +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithParentAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithRecipients +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithSubject +import ch.protonmail.android.mailcomposer.domain.usecase.StoreExternalAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.domain.usecase.isInvalidDueToDisabledAddress +import ch.protonmail.android.mailcomposer.domain.usecase.isInvalidDueToPaidAddress +import ch.protonmail.android.mailcomposer.presentation.mapper.ParticipantMapper +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction +import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerEvent +import ch.protonmail.android.mailcomposer.presentation.model.ComposerOperation +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.DraftUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.reducer.ComposerReducer +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailcomposer.presentation.usecase.ConvertHtmlToPlainText +import ch.protonmail.android.mailcomposer.presentation.usecase.FormatMessageSendingError +import ch.protonmail.android.mailcomposer.presentation.usecase.InjectAddressSignature +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields +import ch.protonmail.android.mailcomposer.presentation.usecase.SortContactsForSuggestions +import ch.protonmail.android.mailcomposer.presentation.usecase.StyleQuotedHtml +import ch.protonmail.android.mailcontact.domain.DeviceContactsSuggestionsPrompt +import ch.protonmail.android.mailcontact.domain.usecase.GetContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchContactGroups +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import ch.protonmail.android.mailcontact.domain.usecase.featureflags.IsDeviceContactsSuggestionsEnabled +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.test.idlingresources.ComposerIdlingResource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.proton.core.network.domain.NetworkManager +import me.proton.core.util.kotlin.deserialize +import me.proton.core.util.kotlin.takeIfNotEmpty +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration + +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") +@Deprecated("Part of Composer V1, to be replaced with ComposerViewModel2") +@HiltViewModel +class ComposerViewModel @Inject constructor( + private val appInBackgroundState: AppInBackgroundState, + private val storeAttachments: StoreAttachments, + private val storeDraftWithBody: StoreDraftWithBody, + private val storeDraftWithSubject: StoreDraftWithSubject, + private val storeDraftWithAllFields: StoreDraftWithAllFields, + private val storeDraftWithRecipients: StoreDraftWithRecipients, + private val storeExternalAttachments: StoreExternalAttachments, + private val getContacts: GetContacts, + private val searchContacts: SearchContacts, + private val searchDeviceContacts: SearchDeviceContacts, + private val deviceContactsSuggestionsPrompt: DeviceContactsSuggestionsPrompt, + private val searchContactGroups: SearchContactGroups, + private val sortContactsForSuggestions: SortContactsForSuggestions, + private val participantMapper: ParticipantMapper, + private val reducer: ComposerReducer, + private val isValidEmailAddress: IsValidEmailAddress, + private val getPrimaryAddress: GetPrimaryAddress, + private val getComposerSenderAddresses: GetComposerSenderAddresses, + private val composerIdlingResource: ComposerIdlingResource, + private val draftUploader: DraftUploader, + private val observeMessageAttachments: ObserveMessageAttachments, + private val observeMessageSendingError: ObserveMessageSendingError, + private val clearMessageSendingError: ClearMessageSendingError, + private val formatMessageSendingError: FormatMessageSendingError, + private val sendMessage: SendMessage, + private val networkManager: NetworkManager, + private val getLocalMessageDecrypted: GetLocalMessageDecrypted, + private val injectAddressSignature: InjectAddressSignature, + private val parentMessageToDraftFields: ParentMessageToDraftFields, + private val styleQuotedHtml: StyleQuotedHtml, + private val storeDraftWithParentAttachments: StoreDraftWithParentAttachments, + private val deleteAttachment: DeleteAttachment, + private val deleteAllAttachments: DeleteAllAttachments, + private val reEncryptAttachments: ReEncryptAttachments, + private val observeMessagePassword: ObserveMessagePassword, + private val validateSenderAddress: ValidateSenderAddress, + private val saveMessageExpirationTime: SaveMessageExpirationTime, + private val observeMessageExpirationTime: ObserveMessageExpirationTime, + private val getExternalRecipients: GetExternalRecipients, + private val convertHtmlToPlainText: ConvertHtmlToPlainText, + isDeviceContactsSuggestionsEnabled: IsDeviceContactsSuggestionsEnabled, + getDecryptedDraftFields: GetDecryptedDraftFields, + savedStateHandle: SavedStateHandle, + observePrimaryUserId: ObservePrimaryUserId, + provideNewDraftId: ProvideNewDraftId, + @DefaultDispatcher val defaultDispatcher: CoroutineDispatcher +) : ViewModel() { + + private val primaryUserId = observePrimaryUserId().filterNotNull() + + private val searchContactsJobs = mutableMapOf() + private val mutableState = MutableStateFlow( + ComposerDraftState.initial( + MessageId(savedStateHandle.get(ComposerScreen.DraftMessageIdKey) ?: provideNewDraftId().id), + // setting the passed recipient directly here in the initial value makes the UX a bit smoother + to = savedStateHandle.extractRecipient() ?: emptyList() + ) + ) + val state: StateFlow = mutableState + val isBodyUpdating = MutableStateFlow(false) + + private val composerActionsChannel = Channel(Channel.BUFFERED) + + init { + val inputDraftId = savedStateHandle.get(ComposerScreen.DraftMessageIdKey) + val draftAction = savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) + ?.deserialize() + val recipientAddress = savedStateHandle.extractRecipient() + + val draftActionForShare = savedStateHandle.get(ComposerScreen.DraftActionForShareKey) + ?.deserialize() + + primaryUserId.onEach { userId -> + getPrimaryAddress(userId) + .onLeft { emitNewStateFor(ComposerEvent.ErrorLoadingDefaultSenderAddress) } + .onRight { + emitNewStateFor(ComposerEvent.DefaultSenderReceived(SenderUiModel(it.email))) + if (isCreatingEmptyDraft(inputDraftId, draftAction)) { + injectAddressSignature(SenderEmail(it.email)) + } + recipientAddress?.let { recipient -> + emitNewStateFor(onToChanged(ComposerAction.RecipientsToChanged(recipient))) + } + } + }.launchIn(viewModelScope) + + when { + inputDraftId != null -> prefillWithExistingDraft(inputDraftId, getDecryptedDraftFields) + draftAction != null -> prefillForDraftAction(draftAction) + draftActionForShare != null -> prefillForShareDraftAction(draftActionForShare) + else -> uploadDraftContinuouslyWhileInForeground(DraftAction.Compose) + } + + observeMessageAttachments() + observeSendingError() + observeMessagePassword() + observeMessageExpirationTime() + observeDeviceContactsSuggestionsPromptEnabled() + + emitNewStateFor(ComposerEvent.OnIsDeviceContactsSuggestionsEnabled(isDeviceContactsSuggestionsEnabled())) + + viewModelScope.launch { + processActions() + } + } + + private fun isCreatingEmptyDraft(inputDraftId: String?, draftAction: DraftAction?): Boolean = + inputDraftId == null && (draftAction == null || draftAction is DraftAction.ComposeToAddresses) + + private fun prefillForShareDraftAction(shareDraftAction: DraftAction.PrefillForShare) { + val fileShareInfo = shareDraftAction.intentShareInfo.decode() + + uploadDraftContinuouslyWhileInForeground(DraftAction.Compose) + + viewModelScope.launch { + fileShareInfo.attachmentUris.takeIfNotEmpty()?.let { uris -> + storeAttachments( + primaryUserId(), + currentMessageId(), + currentSenderEmail(), + uris.map { Uri.parse(it) } + ).onLeft { error -> + if (error is StoreDraftWithAttachmentError.FileSizeExceedsLimit) { + emitNewStateFor(ComposerEvent.ErrorAttachmentsExceedSizeLimit) + } + } + } + + if (fileShareInfo.hasEmailData()) { + emitNewStateFor( + ComposerEvent.PrefillDataReceivedViaShare( + prepareDraftFieldsFor(fileShareInfo).toDraftUiModel() + ) + ) + } + } + } + + private suspend fun prepareDraftFieldsFor(intentShareInfo: IntentShareInfo): DraftFields { + val draftBody = DraftBody(intentShareInfo.emailBody ?: "") + val subject = Subject(intentShareInfo.emailSubject ?: "") + val recipientsTo = RecipientsTo( + intentShareInfo.emailRecipientTo.takeIfNotEmpty()?.map { + participantMapper.recipientUiModelToParticipant(RecipientUiModel.Valid(it), contactsOrEmpty()) + } ?: emptyList() + ) + + val recipientsCc = RecipientsCc( + intentShareInfo.emailRecipientCc.takeIfNotEmpty()?.map { + participantMapper.recipientUiModelToParticipant(RecipientUiModel.Valid(it), contactsOrEmpty()) + } ?: emptyList() + ) + + val recipientsBcc = RecipientsBcc( + intentShareInfo.emailRecipientBcc.takeIfNotEmpty()?.map { + participantMapper.recipientUiModelToParticipant(RecipientUiModel.Valid(it), contactsOrEmpty()) + } ?: emptyList() + ) + + return DraftFields( + currentSenderEmail(), + subject, + draftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + } + + private fun prefillForDraftAction(draftAction: DraftAction) { + val parentMessageId = draftAction.getParentMessageId() ?: return + Timber.d("Opening composer for draft action ${draftAction::class.java.simpleName} / ${currentMessageId()}") + emitNewStateFor(ComposerEvent.OpenWithMessageAction(currentMessageId(), draftAction)) + + viewModelScope.launch { + val parentMessage = getLocalMessageDecrypted(primaryUserId(), parentMessageId) + .onLeft { emitNewStateFor(ComposerEvent.ErrorLoadingParentMessageData) } + .getOrNull() + ?: return@launch + + val draftFields = parentMessageToDraftFields(primaryUserId(), parentMessage, draftAction) + .onLeft { emitNewStateFor(ComposerEvent.ErrorLoadingParentMessageData) } + .getOrNull() + ?: return@launch + + val senderValidationResult = validateSenderAddress(primaryUserId(), draftFields.sender) + .onLeft { emitNewStateFor(ComposerEvent.ErrorLoadingParentMessageData) } + .getOrNull() + ?: return@launch + + uploadDraftContinuouslyWhileInForeground(draftAction) + val validatedSender = senderValidationResult.validAddress + + emitNewStateFor( + ComposerEvent.PrefillDraftDataReceived( + draftUiModel = draftFields.copy(sender = validatedSender).toDraftUiModel(), + isDataRefreshed = true, + isBlockedSendingFromPmAddress = senderValidationResult.isInvalidDueToPaidAddress(), + isBlockedSendingFromDisabledAddress = senderValidationResult.isInvalidDueToDisabledAddress() + ) + ) + storeDraftWithParentAttachments.invoke( + primaryUserId(), + currentMessageId(), + parentMessage, + validatedSender, + draftAction + ) + + // User may skip editing Subject line, so we need to store it here. + storeDraftWithSubject( + primaryUserId(), currentMessageId(), validatedSender, draftFields.subject + ) + + if (senderValidationResult is ValidateSenderAddress.ValidationResult.Invalid) { + reEncryptAttachments( + userId = primaryUserId(), + messageId = currentMessageId(), + previousSender = senderValidationResult.invalid, + newSenderEmail = validatedSender + ).onLeft { + Timber.e("Failed to re-encrypt attachments: $it") + handleReEncryptionFailed() + } + } + } + } + + private fun prefillWithExistingDraft(inputDraftId: String, getDecryptedDraftFields: GetDecryptedDraftFields) { + Timber.d("Opening composer with $inputDraftId / ${currentMessageId()}") + emitNewStateFor(ComposerEvent.OpenExistingDraft(currentMessageId())) + + viewModelScope.launch { + getDecryptedDraftFields(primaryUserId(), currentMessageId()) + .onRight { draftFields -> + uploadDraftContinuouslyWhileInForeground(DraftAction.Compose) + storeExternalAttachments(primaryUserId(), currentMessageId()) + emitNewStateFor( + ComposerEvent.PrefillDraftDataReceived( + draftUiModel = draftFields.draftFields.toDraftUiModel(), + isDataRefreshed = draftFields is DecryptedDraftFields.Remote, + isBlockedSendingFromPmAddress = false, + isBlockedSendingFromDisabledAddress = false + ) + ) + } + .onLeft { emitNewStateFor(ComposerEvent.ErrorLoadingDraftData) } + + } + } + + private fun DraftFields.toDraftUiModel(): DraftUiModel { + val quotedHtml = this.originalHtmlQuote?.let { + QuotedHtmlContent(it, styleQuotedHtml(it)) + } + return DraftUiModel(this, quotedHtml) + } + + override fun onCleared() { + super.onCleared() + composerIdlingResource.clear() + } + + internal fun submit(action: ComposerAction) { + viewModelScope.launch { + logViewModelAction(action, "Enqueued.") + composerActionsChannel.send(action) + } + } + + @Suppress("ComplexMethod") + private suspend fun processActions() { + composerActionsChannel.consumeEach { action -> + logViewModelAction(action, "Executing.") + composerIdlingResource.increment() + when (action) { + is ComposerAction.AttachmentsAdded -> onAttachmentsAdded(action) + is ComposerAction.DraftBodyChanged -> onDraftBodyChanged(action) + + is ComposerAction.SenderChanged -> emitNewStateFor(onSenderChanged(action)) + is ComposerAction.SubjectChanged -> emitNewStateFor(onSubjectChanged(action)) + is ComposerAction.ChangeSenderRequested -> emitNewStateFor(onChangeSender()) + is ComposerAction.RecipientsToChanged -> emitNewStateFor(onToChanged(action)) + is ComposerAction.RecipientsCcChanged -> emitNewStateFor(onCcChanged(action)) + is ComposerAction.RecipientsBccChanged -> emitNewStateFor(onBccChanged(action)) + is ComposerAction.ContactSuggestionTermChanged -> onSearchTermChanged( + action.searchTerm, + action.suggestionsField + ) + + is ComposerAction.ContactSuggestionsDismissed -> emitNewStateFor(action) + is ComposerAction.DeviceContactsPromptDenied -> onDeviceContactsPromptDenied() + is ComposerAction.OnAddAttachments -> emitNewStateFor(action) + is ComposerAction.OnCloseComposer -> emitNewStateFor(onCloseComposer(action)) + is ComposerAction.OnSendMessage -> emitNewStateFor(handleOnSendMessage(action)) + is ComposerAction.ConfirmSendingWithoutSubject -> emitNewStateFor(onSendMessage(action)) + is ComposerAction.RejectSendingWithoutSubject -> emitNewStateFor(action) + is ComposerAction.RemoveAttachment -> onAttachmentsRemoved(action) + is ComposerAction.OnSetExpirationTimeRequested -> emitNewStateFor(action) + is ComposerAction.ExpirationTimeSet -> onExpirationTimeSet(action) + is ComposerAction.SendExpiringMessageToExternalRecipientsConfirmed -> emitNewStateFor( + onSendMessage(action) + ) + + is ComposerAction.RespondInlineRequested -> onRespondInline() + } + composerIdlingResource.decrement() + + logViewModelAction(action, "Completed.") + } + } + + private fun uploadDraftContinuouslyWhileInForeground(draftAction: DraftAction) { + appInBackgroundState.observe().onEach { isAppInBackground -> + if (isAppInBackground) { + Timber.d("App is in background, stop continuous upload") + draftUploader.stopContinuousUpload() + } else { + Timber.d("App is in foreground, start continuous upload") + draftUploader.startContinuousUpload( + primaryUserId(), currentMessageId(), draftAction, this.viewModelScope + ) + } + }.launchIn(viewModelScope) + } + + private fun observeMessageAttachments() { + primaryUserId + .flatMapLatest { userId -> observeMessageAttachments(userId, currentMessageId()) } + .onEach { emitNewStateFor(ComposerEvent.OnAttachmentsUpdated(it)) } + .launchIn(viewModelScope) + } + + private fun observeSendingError() { + primaryUserId + .flatMapLatest { userId -> observeMessageSendingError(userId, currentMessageId()) } + .onEach { + formatMessageSendingError(it)?.run { + emitNewStateFor(ComposerEvent.OnSendingError(TextUiModel.Text(this))) + } + } + .launchIn(viewModelScope) + } + + private fun observeMessagePassword() { + primaryUserId + .flatMapLatest { userId -> observeMessagePassword(userId, currentMessageId()) } + .onEach { emitNewStateFor(ComposerEvent.OnMessagePasswordUpdated(it)) } + .launchIn(viewModelScope) + } + + private fun observeMessageExpirationTime() { + primaryUserId + .flatMapLatest { userId -> observeMessageExpirationTime(userId, currentMessageId()) } + .onEach { emitNewStateFor(ComposerEvent.OnMessageExpirationTimeUpdated(it)) } + .launchIn(viewModelScope) + } + + @Suppress("FunctionMaxLength") + private fun observeDeviceContactsSuggestionsPromptEnabled() { + viewModelScope.launch { + emitNewStateFor( + ComposerEvent.OnIsDeviceContactsSuggestionsPromptEnabled( + deviceContactsSuggestionsPrompt.getPromptEnabled() + ) + ) + } + } + + fun validateEmailAddress(emailAddress: String): Boolean = isValidEmailAddress(emailAddress) + + fun clearSendingError() { + viewModelScope.launch { + clearMessageSendingError(primaryUserId(), currentMessageId()).onLeft { + Timber.e("Failed to clear SendingError: $it") + } + } + } + + private fun onAttachmentsAdded(action: ComposerAction.AttachmentsAdded) { + viewModelScope.launch { + storeAttachments(primaryUserId(), currentMessageId(), currentSenderEmail(), action.uriList).onLeft { + if (it is StoreDraftWithAttachmentError.FileSizeExceedsLimit) { + emitNewStateFor(ComposerEvent.ErrorAttachmentsExceedSizeLimit) + } + } + } + } + + private fun onAttachmentsRemoved(action: ComposerAction.RemoveAttachment) { + viewModelScope.launch { + deleteAttachment(primaryUserId(), currentSenderEmail(), currentMessageId(), action.attachmentId) + .onLeft { Timber.e("Failed to delete attachment: $it") } + } + } + + private fun onExpirationTimeSet(action: ComposerAction.ExpirationTimeSet) { + viewModelScope.launch { + saveMessageExpirationTime(primaryUserId(), currentMessageId(), currentSenderEmail(), action.duration).fold( + ifLeft = { emitNewStateFor(ComposerEvent.ErrorSettingExpirationTime) }, + ifRight = { emitNewStateFor(action) } + ) + } + } + + private fun onDeviceContactsPromptDenied() { + viewModelScope.launch { + deviceContactsSuggestionsPrompt.setPromptDisabled() + } + } + + private suspend fun onCloseComposer(action: ComposerAction.OnCloseComposer): ComposerOperation { + val draftFields = buildDraftFields() + return when { + draftFields.haveBlankRecipients() && + draftFields.haveBlankSubject() && + isBodyEmptyOrEqualsToSignatureAndFooter(currentDraftBody()) -> action + + else -> { + draftUploader.stopContinuousUpload() + storeDraftWithAllFields(primaryUserId(), currentMessageId(), draftFields) + draftUploader.upload(primaryUserId(), currentMessageId()) + + ComposerEvent.OnCloseWithDraftSaved + } + } + } + + private suspend fun handleOnSendMessage(action: ComposerAction.OnSendMessage): ComposerOperation { + val draftFields = buildDraftFields() + return if (draftFields.haveBlankSubject()) { + ComposerEvent.ConfirmEmptySubject + } else if (state.value.messageExpiresIn != Duration.ZERO) { + if (!state.value.isMessagePasswordSet) { + val externalRecipients = draftFields.let { + getExternalRecipients(primaryUserId(), it.recipientsTo, it.recipientsCc, it.recipientsBcc) + } + if (externalRecipients.isNotEmpty()) { + ComposerEvent.ConfirmSendExpiringMessageToExternalRecipients(externalRecipients) + } else { + onSendMessage(action) + } + } else { + onSendMessage(action) + } + } else { + onSendMessage(action) + } + } + + private fun onRespondInline() { + state.value.fields.quotedBody?.let { quotedHtmlContent -> + val plainTextQuotedContent = convertHtmlToPlainText(quotedHtmlContent.styled.value) + emitNewStateFor(ComposerEvent.RespondInlineContent(plainTextQuotedContent)) + } + } + + private suspend fun onSendMessage(action: ComposerOperation): ComposerOperation { + val draftFields = buildDraftFields() + return when { + draftFields.areBlank() -> action + else -> { + draftUploader.stopContinuousUpload() + sendMessage(primaryUserId(), currentMessageId(), draftFields) + + if (networkManager.isConnectedToNetwork()) { + ComposerAction.OnSendMessage + } else { + ComposerEvent.OnSendMessageOffline + } + } + } + } + + private suspend fun buildDraftFields() = withContext(defaultDispatcher) { + DraftFields( + currentSenderEmail(), + currentSubject(), + currentDraftBody(), + currentValidRecipientsTo(), + currentValidRecipientsCc(), + currentValidRecipientsBcc(), + currentDraftQuotedHtmlBody() + ) + } + + private suspend fun onSubjectChanged(action: ComposerAction.SubjectChanged): ComposerOperation = + storeDraftWithSubject(primaryUserId.first(), currentMessageId(), currentSenderEmail(), action.subject).fold( + ifLeft = { + Timber.e("Store draft ${currentMessageId()} with new subject ${action.subject} failed") + ComposerEvent.ErrorStoringDraftSubject + }, + ifRight = { action } + ) + + private suspend fun onSenderChanged(action: ComposerAction.SenderChanged): ComposerOperation = storeDraftWithBody( + primaryUserId(), + currentMessageId(), + currentDraftBody(), + currentDraftQuotedHtmlBody(), + SenderEmail(action.sender.email) + ) + .onRight { + reEncryptAttachments( + userId = primaryUserId(), + messageId = currentMessageId(), + previousSender = SenderEmail(state.value.fields.sender.email), + newSenderEmail = SenderEmail(action.sender.email) + ).onLeft { + Timber.e("Failed to re-encrypt attachments: $it") + handleReEncryptionFailed() + } + } + .fold( + ifLeft = { + Timber.e("Store draft ${currentMessageId()} with new sender ${action.sender.email} failed") + ComposerEvent.ErrorStoringDraftSenderAddress + }, + ifRight = { + injectAddressSignature( + senderEmail = SenderEmail(action.sender.email), + previousSenderEmail = currentSenderEmail() + ) + action + } + ) + + private suspend fun onDraftBodyChanged(action: ComposerAction.DraftBodyChanged) { + isBodyUpdating.value = true + + emitNewStateFor(ComposerAction.DraftBodyChanged(action.draftBody)) + + // Do not store the draft if the body is exactly the same as signature + footer. + if (isBodyEmptyOrEqualsToSignatureAndFooter(action.draftBody)) { + isBodyUpdating.value = false + return + } + + storeDraftWithBody( + primaryUserId(), + currentMessageId(), + action.draftBody, + currentDraftQuotedHtmlBody(), + currentSenderEmail() + ).onLeft { emitNewStateFor(ComposerEvent.ErrorStoringDraftBody) } + + isBodyUpdating.value = false + } + + private suspend fun injectAddressSignature(senderEmail: SenderEmail, previousSenderEmail: SenderEmail? = null) { + injectAddressSignature(primaryUserId(), currentDraftBody(), senderEmail, previousSenderEmail).getOrNull()?.let { + emitNewStateFor(ComposerEvent.ReplaceDraftBody(it)) + } + } + + private suspend fun isBodyEmptyOrEqualsToSignatureAndFooter(draftBody: DraftBody): Boolean { + // Consider the body empty even if it has white spaces or newlines. + if (draftBody.value.trim().isEmpty()) return true + + val bodyWithSignature = injectAddressSignature( + primaryUserId(), + DraftBody(""), + currentSenderEmail() + ) + + val isBodyEqualSignature = bodyWithSignature.getOrNull()?.value == draftBody.value + return draftBody.value.isNotBlank() && isBodyEqualSignature + } + + private suspend fun primaryUserId() = primaryUserId.first() + + private fun currentSubject() = Subject(state.value.fields.subject) + + private fun currentDraftBody() = DraftBody(state.value.fields.body) + + private fun currentDraftQuotedHtmlBody(): OriginalHtmlQuote? = state.value.fields.quotedBody?.original + + private fun currentSenderEmail() = SenderEmail(state.value.fields.sender.email) + + private fun currentMessageId() = state.value.fields.draftId + + private suspend fun currentValidRecipientsTo(): RecipientsTo { + val contacts = contactsOrEmpty() + return RecipientsTo( + state.value.fields.to.filterIsInstance().map { + participantMapper.recipientUiModelToParticipant(it, contacts) + } + ) + } + + private suspend fun currentValidRecipientsCc(): RecipientsCc { + val contacts = contactsOrEmpty() + return RecipientsCc( + state.value.fields.cc.filterIsInstance().map { + participantMapper.recipientUiModelToParticipant(it, contacts) + } + ) + } + + private suspend fun currentValidRecipientsBcc(): RecipientsBcc { + val contacts = contactsOrEmpty() + return RecipientsBcc( + state.value.fields.bcc.filterIsInstance().map { + participantMapper.recipientUiModelToParticipant(it, contacts) + } + ) + } + + private suspend fun contactsOrEmpty() = getContacts(primaryUserId()).getOrElse { emptyList() } + + private suspend fun onChangeSender() = getComposerSenderAddresses().fold( + ifLeft = { changeSenderError -> + when (changeSenderError) { + Error.UpgradeToChangeSender -> ComposerEvent.ErrorFreeUserCannotChangeSender + Error.FailedDeterminingUserSubscription, + Error.FailedGettingPrimaryUser -> ComposerEvent.ErrorVerifyingPermissionsToChangeSender + } + }, + ifRight = { userAddresses -> + ComposerEvent.SenderAddressesReceived(userAddresses.map { SenderUiModel(it.email) }) + } + ) + + private suspend fun onToChanged(action: ComposerAction.RecipientsToChanged): ComposerOperation { + val contacts = contactsOrEmpty() + return action.recipients.filterIsInstance().takeIfNotEmpty()?.let { validRecipients -> + storeDraftWithRecipients( + primaryUserId(), + currentMessageId(), + currentSenderEmail(), + to = validRecipients.map { participantMapper.recipientUiModelToParticipant(it, contacts) } + ).fold( + ifLeft = { ComposerEvent.ErrorStoringDraftRecipients }, + ifRight = { action } + ) + } ?: action + } + + + private suspend fun onCcChanged(action: ComposerAction.RecipientsCcChanged): ComposerOperation { + val contacts = contactsOrEmpty() + return action.recipients.filterIsInstance().takeIfNotEmpty()?.let { validRecipients -> + storeDraftWithRecipients( + primaryUserId(), + currentMessageId(), + currentSenderEmail(), + cc = validRecipients.map { participantMapper.recipientUiModelToParticipant(it, contacts) } + ).fold( + ifLeft = { ComposerEvent.ErrorStoringDraftRecipients }, + ifRight = { action } + ) + } ?: action + } + + + private suspend fun onBccChanged(action: ComposerAction.RecipientsBccChanged): ComposerOperation { + val contacts = contactsOrEmpty() + return action.recipients.filterIsInstance().takeIfNotEmpty()?.let { validRecipients -> + storeDraftWithRecipients( + primaryUserId(), + currentMessageId(), + currentSenderEmail(), + bcc = validRecipients.map { participantMapper.recipientUiModelToParticipant(it, contacts) } + ).fold( + ifLeft = { ComposerEvent.ErrorStoringDraftRecipients }, + ifRight = { action } + ) + } ?: action + } + + private suspend fun onSearchTermChanged(searchTerm: String, suggestionsField: ContactSuggestionsField) { + + // cancel previous search Job for this [suggestionsField] type + searchContactsJobs[suggestionsField]?.cancel() + + if (searchTerm.isNotBlank()) { + searchContactsJobs[suggestionsField] = combine( + searchContacts(primaryUserId(), searchTerm, onlyMatchingContactEmails = true), + searchContactGroups(primaryUserId(), searchTerm) + ) { contacts, contactGroups -> + + val deviceContacts = if (state.value.isDeviceContactsSuggestionsEnabled) { + searchDeviceContacts(searchTerm).getOrNull() ?: emptyList() + } else emptyList() + + val suggestions = sortContactsForSuggestions( + contacts.getOrNull() ?: emptyList(), + deviceContacts, + contactGroups.getOrNull() ?: emptyList(), + maxContactAutocompletionCount + ) + + emitNewStateFor( + ComposerEvent.UpdateContactSuggestions( + suggestions, + suggestionsField + ) + ) + + }.launchIn(viewModelScope) + } else { + emitNewStateFor( + ComposerAction.ContactSuggestionsDismissed( + suggestionsField + ) + ) + } + + } + + private suspend fun handleReEncryptionFailed() { + deleteAllAttachments(primaryUserId(), currentSenderEmail(), currentMessageId()) + emitNewStateFor(ComposerEvent.ErrorAttachmentsReEncryption) + } + + private fun emitNewStateFor(operation: ComposerOperation) { + val currentState = state.value + mutableState.value = reducer.newStateFrom(currentState, operation) + } + + private fun SavedStateHandle.extractRecipient(): List? { + return get(ComposerScreen.SerializedDraftActionKey)?.deserialize() + .let { it as? DraftAction.ComposeToAddresses } + ?.let { + it.recipients.map { recipient -> + when { + validateEmailAddress(recipient) -> RecipientUiModel.Valid(recipient) + else -> RecipientUiModel.Invalid(recipient) + } + } + } + } + + // Function to log at the debug level within the Composer ViewModel scope. + // This is introduced to identify potential issues with the current mutex implementation and determine + // whether and how we can properly remove it. + private fun logViewModelAction(action: ComposerAction, message: String) { + // Ignore DraftBodyChanged for now, it causes too much noise. + if (action is ComposerAction.DraftBodyChanged) return + + Timber + .tag("ComposerViewModel") + .d("Action ${action::class.java.simpleName} ${System.identityHashCode(action)} - $message") + } + + companion object { + + internal const val maxContactAutocompletionCount = 100 + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2.kt new file mode 100644 index 0000000000..821e13955d --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2.kt @@ -0,0 +1,786 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.getOrElse +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailcommon.domain.model.decode +import ch.protonmail.android.mailcommon.domain.model.hasEmailData +import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses.Error +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.facade.AddressesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.AttachmentsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.DraftFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageAttributesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageContentFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageParticipantsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageSendingFacade +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerStates +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.ObservedComposerDataChanges +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.model.operations.AccessoriesEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.AttachmentsEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.ComposerAction2 +import ch.protonmail.android.mailcomposer.presentation.model.operations.ComposerStateEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.CompositeEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.EffectsEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.MainEvent +import ch.protonmail.android.mailcomposer.presentation.model.toParticipantFields +import ch.protonmail.android.mailcomposer.presentation.reducer.ComposerStateReducer +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailcomposer.presentation.ui.form.EmailValidator +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.proton.core.domain.entity.UserId +import me.proton.core.network.domain.NetworkManager +import me.proton.core.util.kotlin.deserialize +import me.proton.core.util.kotlin.takeIfNotEmpty +import timber.log.Timber +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Suppress("TooManyFunctions", "LargeClass") +@HiltViewModel(assistedFactory = ComposerViewModel2.Factory::class) +class ComposerViewModel2 @AssistedInject constructor( + private val draftFacade: DraftFacade, + private val attachmentsFacade: AttachmentsFacade, + private val messageAttributesFacade: MessageAttributesFacade, + private val messageContentFacade: MessageContentFacade, + private val messageParticipantsFacade: MessageParticipantsFacade, + private val messageSendingFacade: MessageSendingFacade, + private val addressesFacade: AddressesFacade, + private val appInBackgroundState: AppInBackgroundState, + private val networkManager: NetworkManager, + private val savedStateHandle: SavedStateHandle, + private val composerStateReducer: ComposerStateReducer, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @Assisted private val recipientsStateManager: RecipientsStateManager, + private val shouldRestrictWebViewHeight: ShouldRestrictWebViewHeight, + private val buildVersionProvider: BuildVersionProvider +) : ViewModel() { + + internal val subjectTextField = TextFieldState() + internal val bodyFieldText = TextFieldState() + + private val primaryUserId = messageParticipantsFacade.observePrimaryUserId() + private var pendingStoreDraftJob: Job? = null + + private val composerActionsChannel = Channel(Channel.BUFFERED) + + private val mutableComposerStates = MutableStateFlow( + ComposerStates( + main = ComposerState.Main.initial(draftId = MessageId(resolveDraftId())), + attachments = ComposerState.Attachments.initial(), + accessories = ComposerState.Accessories.initial(), + effects = ComposerState.Effects.initial() + ) + ) + + internal val composerStates = mutableComposerStates.asStateFlow() + + private val draftAction = savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) + ?.deserialize() + + init { + viewModelScope.launch { + if (!setupInitialState(draftAction)) return@launch + + uploadDraftContinuouslyWhileInForeground() + + observeComposerFields() + observeAttachments() + observeMessagePassword() + observeMessageExpiration() + observePendingSendingError() + + processActions() + } + } + + internal fun submitAction(action: ComposerAction2) { + viewModelScope.launch { + composerActionsChannel.send(action) + logViewModelAction(action, "Enqueued") + } + } + + private suspend fun setupInitialState(draftAction: DraftAction?): Boolean { + emitNewStateFromOperation(MainEvent.InitialLoadingToggled) + val inputDraftId = savedStateHandle.get(ComposerScreen.DraftMessageIdKey) + + val draftActionForShare = savedStateHandle.get(ComposerScreen.DraftActionForShareKey) + ?.deserialize() + val restoredHandle = savedStateHandle.get(ComposerScreen.HasSavedDraftKey) == true + + when { + restoredHandle -> { + emitNewStateFromOperation(onComposerRestored()) + return false + } + + inputDraftId != null -> prefillWithExistingDraft(inputDraftId) + draftAction != null -> prefillForDraftAction(draftAction) + draftActionForShare != null -> prefillForShareDraftAction(draftActionForShare) + else -> setupStandaloneDraft(inputDraftId, draftAction) + } + + emitNewStateFromOperation(MainEvent.LoadingDismissed) + + return true + } + + @OptIn(FlowPreview::class) + private fun observeComposerFields() { + val combinedFlow = combine( + composerStates.map { it.main.senderUiModel }.distinctUntilChanged(), + recipientsStateManager.recipients, + snapshotFlow { subjectTextField.text }, + snapshotFlow { bodyFieldText.text } + ) { sender, recipients, subject, body -> + ObservedComposerDataChanges( + SenderEmail(sender.email), + recipients, + Subject(subject.toString().stripNewLines()), + DraftBody(body.toString()) + ) + } + + // Keep recipients validations separate from actual saving. + combinedFlow.map { it.recipients } + .distinctUntilChanged() + .onEach { + emitNewStateFromOperation(MainEvent.RecipientsChanged(recipientsStateManager.hasValidRecipients())) + } + .launchIn(viewModelScope) + + combinedFlow.debounce(timeout = 1.seconds).onEach { it -> + pendingStoreDraftJob = viewModelScope.launch(defaultDispatcher) { + Timber.tag("ComposerViewModel").d("Saving draft..") + + val (toParticipants, ccParticipants, bccParticipants) = it.recipients.toParticipantFields { recipient -> + messageParticipantsFacade.mapToParticipant(recipient) + } + + val draftFields = DraftFields( + it.sender, + it.subject, + it.body, + RecipientsTo(toParticipants), + RecipientsCc(ccParticipants), + RecipientsBcc(bccParticipants), + composerStates.value.main.quotedHtmlContent?.original + ) + + if (shouldSkipSave(draftFields)) { + Timber.tag("ComposerViewModel").d("Not saving draft") + return@launch + } + + draftFacade.storeDraft( + userId = primaryUserId.first(), + draftMessageId = currentMessageId(), + fields = draftFields, + action = currentDraftActionOrDefault() + ) + + savedStateHandle[ComposerScreen.HasSavedDraftKey] = true + Timber.tag("ComposerViewModel").d("Draft saved.") + } + }.launchIn(viewModelScope) + } + + private fun observeAttachments() { + primaryUserId + .flatMapLatest { userId -> attachmentsFacade.observeMessageAttachments(userId, currentMessageId()) } + .onEach { emitNewStateFromOperation(AttachmentsEvent.OnListChanged(it)) } + .launchIn(viewModelScope) + } + + private fun observeMessagePassword() { + primaryUserId + .flatMapLatest { userId -> messageAttributesFacade.observeMessagePassword(userId, currentMessageId()) } + .onEach { emitNewStateFromOperation(AccessoriesEvent.OnPasswordChanged(it)) } + .launchIn(viewModelScope) + } + + private fun observeMessageExpiration() { + primaryUserId + .flatMapLatest { userId -> messageAttributesFacade.observeMessageExpiration(userId, currentMessageId()) } + .onEach { emitNewStateFromOperation(AccessoriesEvent.OnExpirationChanged(it)) } + .launchIn(viewModelScope) + } + + private fun observePendingSendingError() { + primaryUserId + .flatMapLatest { userId -> messageSendingFacade.observeAndFormatSendingErrors(userId, currentMessageId()) } + .filterNotNull() + .onEach { emitNewStateFromOperation(EffectsEvent.SendEvent.OnSendingError(it)) } + .launchIn(viewModelScope) + } + + private suspend fun setupStandaloneDraft( + inputDraftId: String?, + draftAction: DraftAction?, + recipients: List? = null + ) { + addressesFacade.getPrimarySenderEmail(primaryUserId.first()) + .onLeft { + return emitNewStateFromOperation(EffectsEvent.LoadingEvent.OnSenderAddressLoadingFailed) + } + .onRight { value -> + emitNewStateFromOperation(MainEvent.SenderChanged(value)) + + if (isCreatingEmptyDraft(inputDraftId, draftAction)) { + injectAddressSignature(value) + } + + recipients?.let { recipient -> + recipientsStateManager.updateRecipients(recipient, ContactSuggestionsField.TO) + } + } + } + + private fun onComposerRestored(): EffectsEvent { + // This is hit when process death occurs and the user could be in an inconsistent state: + // Theoretically we can restore the draft from local storage, but it's not guaranteed that its content is + // up to date and we don't know if it should overwrite the remote state. + Timber.tag("ComposerViewModel").d("Restored Composer instance - navigating back.") + return EffectsEvent.ComposerControlEvent.OnComposerRestored + } + + private suspend fun prefillForDraftAction(draftAction: DraftAction) { + val parentMessageId = draftAction.getParentMessageId() + ?: return setupStandaloneDraft(null, draftAction, savedStateHandle.extractRecipient()) + + val userId = primaryUserId.first() + Timber.d("Opening composer for draft action ${draftAction::class.java.simpleName} / ${currentMessageId()}") + + val (parentMessage, draftFields) = draftFacade + .parentMessageToDraftFields(userId, parentMessageId, draftAction) + ?: return emitNewStateFromOperation(EffectsEvent.LoadingEvent.OnParentLoadingFailed) + + val senderValidationResult = addressesFacade.validateSenderAddress(userId, draftFields.sender).getOrNull() + ?: return emitNewStateFromOperation(EffectsEvent.LoadingEvent.OnSenderAddressLoadingFailed) + + bodyFieldText.replaceText(draftFields.body.value, resetRange = true) + subjectTextField.replaceText(draftFields.subject.value) + + recipientsStateManager.setFromParticipants( + toRecipients = draftFields.recipientsTo.value, + ccRecipients = draftFields.recipientsCc.value, + bccRecipients = draftFields.recipientsBcc.value + ) + val validatedSender = senderValidationResult.validAddress + + val quotedHtmlContent = draftFields.originalHtmlQuote.toQuotedContent() + + val shouldRestrictWebViewHeight = shouldRestrictWebViewHeight(null) && + buildVersionProvider.sdkInt() == Build.VERSION_CODES.P + + emitNewStateFromOperation( + CompositeEvent.DraftContentReady( + senderEmail = validatedSender.value, + isDataRefreshed = true, + senderValidationResult = senderValidationResult, + quotedHtmlContent = quotedHtmlContent, + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight, + forceBodyFocus = draftAction is DraftAction.Reply || draftAction is DraftAction.ReplyAll + ) + ) + + draftFacade.storeDraftWithParentAttachments( + userId, + currentMessageId(), + parentMessage, + validatedSender, + draftAction + ) + + if (senderValidationResult is ValidateSenderAddress.ValidationResult.Invalid) { + attachmentsFacade.reEncryptAttachments( + userId = userId, + messageId = currentMessageId(), + previousSender = senderValidationResult.invalid, + newSender = validatedSender + ).onLeft { + Timber.e("Failed to re-encrypt attachments: $it") + handleReEncryptionFailed(userId, senderValidationResult.validAddress, currentMessageId()) + } + } + } + + private suspend fun handleReEncryptionFailed( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId + ) { + attachmentsFacade.deleteAllAttachments(userId, senderEmail, messageId) + emitNewStateFromOperation(EffectsEvent.AttachmentEvent.ReEncryptError) + } + + private suspend fun prefillWithExistingDraft(inputDraftId: String) { + Timber.d("Opening composer with $inputDraftId / ${mutableComposerStates.value.main.draftId}") + + draftFacade.getDecryptedDraftFields(primaryUserId.first(), currentMessageId()) + .onRight { draftFields -> + attachmentsFacade.storeExternalAttachments(primaryUserId.first(), currentMessageId()) + + val fields = draftFields.draftFields + bodyFieldText.replaceText(fields.body.value, resetRange = true) + subjectTextField.replaceText(fields.subject.value) + recipientsStateManager.setFromParticipants( + toRecipients = fields.recipientsTo.value, + ccRecipients = fields.recipientsCc.value, + bccRecipients = fields.recipientsBcc.value + ) + + val shouldRestrictWebViewHeight = shouldRestrictWebViewHeight(null) && + buildVersionProvider.sdkInt() == Build.VERSION_CODES.P + + emitNewStateFromOperation( + CompositeEvent.DraftContentReady( + senderEmail = fields.sender.value, + isDataRefreshed = draftFields is DecryptedDraftFields.Remote, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(fields.sender), + quotedHtmlContent = fields.originalHtmlQuote.toQuotedContent(), + shouldRestrictWebViewHeight = shouldRestrictWebViewHeight, + forceBodyFocus = false + ) + ) + } + .onLeft { + emitNewStateFromOperation(EffectsEvent.DraftEvent.OnDraftLoadingFailed) + } + } + + private suspend fun OriginalHtmlQuote?.toQuotedContent() = this?.let { + QuotedHtmlContent(original = it, styled = messageContentFacade.styleQuotedHtml(it)) + } + + private suspend fun prefillForShareDraftAction(shareDraftAction: DraftAction.PrefillForShare) { + val fileShareInfo = shareDraftAction.intentShareInfo.decode() + val userId = primaryUserId.first() + val senderEmail = addressesFacade.getPrimarySenderEmail(primaryUserId.first()) + .getOrNull() + ?: return emitNewStateFromOperation(EffectsEvent.LoadingEvent.OnSenderAddressLoadingFailed) + + fileShareInfo.attachmentUris.takeIfNotEmpty()?.let { uris -> + attachmentsFacade.storeAttachments( + userId, + currentMessageId(), + senderEmail, + uris.map { it.toUri() } + ).onLeft { error -> + Timber.e("Error storing attachment - Share Via flow - $error") + emitNewStateFromOperation(EffectsEvent.AttachmentEvent.Error(error)) + } + } + + if (fileShareInfo.hasEmailData()) { + updateFieldsFromShareInfo(fileShareInfo) + } + + val sharedDraftBody = DraftBody(fileShareInfo.emailBody ?: "") + injectAddressSignature(senderEmail = senderEmail, draftBody = sharedDraftBody) + + emitNewStateFromOperation(MainEvent.SenderChanged(senderEmail)) + } + + private fun updateFieldsFromShareInfo(intentShareInfo: IntentShareInfo) { + recipientsStateManager.setFromRawRecipients( + toRecipients = intentShareInfo.emailRecipientTo, + ccRecipients = intentShareInfo.emailRecipientCc, + bccRecipients = intentShareInfo.emailRecipientBcc + ) + + subjectTextField.replaceText(intentShareInfo.emailSubject.orEmpty()) + } + + private fun uploadDraftContinuouslyWhileInForeground() { + appInBackgroundState.observe().onEach { isAppInBackground -> + if (isAppInBackground) { + Timber.d("App is in background, stop continuous upload") + draftFacade.stopContinuousUpload() + } else { + Timber.d("App is in foreground, start continuous upload") + draftFacade.startContinuousUpload( + primaryUserId.first(), currentMessageId(), currentDraftActionOrDefault(), this.viewModelScope + ) + } + }.launchIn(viewModelScope) + } + + private suspend fun injectAddressSignature( + senderEmail: SenderEmail, + previousSenderEmail: SenderEmail? = null, + draftBody: DraftBody = DraftBody(bodyFieldText.text.toString()) + ) { + draftFacade.injectAddressSignature( + primaryUserId.first(), + draftBody, + senderEmail, + previousSenderEmail + ).getOrNull() + ?.let { it: DraftBody -> bodyFieldText.replaceText(it.value, resetRange = true) } + } + + private suspend fun processActions() { + composerActionsChannel.consumeEach { action -> + logViewModelAction(action, "Executing") + when (action) { + is ComposerAction2.ChangeSender -> onChangeSenderRequested() + is ComposerAction2.SetSenderAddress -> onSetNewSender(action.sender) + is ComposerAction2.RespondInline -> onRespondInline() + + is ComposerAction2.OpenExpirationSettings -> + emitNewStateFromOperation(EffectsEvent.SetExpirationReady) + + is ComposerAction2.SetMessageExpiration -> onExpirationSet(action.duration) + + is ComposerAction2.OpenFilePicker -> + emitNewStateFromOperation(EffectsEvent.AttachmentEvent.OnAddRequest) + + is ComposerAction2.StoreAttachments -> onStoreAttachments(action.uriList) + is ComposerAction2.RemoveAttachment -> onAttachmentsRemoved(action.attachmentId) + + is ComposerAction2.CloseComposer -> onCloseComposer() + is ComposerAction2.SendMessage -> handleOnSendMessage() + + is ComposerAction2.CancelSendWithNoSubject -> + emitNewStateFromOperation(EffectsEvent.SendEvent.OnCancelSendNoSubject) + + is ComposerAction2.ConfirmSendWithNoSubject -> onSendMessage(currentDraftFields()) + + is ComposerAction2.CancelSendExpirationSetToExternal -> + emitNewStateFromOperation(MainEvent.LoadingDismissed) + + is ComposerAction2.ConfirmSendExpirationSetToExternal -> onSendMessage(currentDraftFields()) + + is ComposerAction2.ClearSendingError -> onClearSendingError() + } + logViewModelAction(action, "Completed.") + } + } + + private suspend fun onRespondInline() { + val quotedHtmlContent = composerStates.value.main.quotedHtmlContent ?: run { + Timber.d("Expected quoteHtmlContent, got null") + return + } + + val plainTextQuotedContent = messageContentFacade.convertHtmlToPlainText(quotedHtmlContent.styled.value) + + bodyFieldText.edit { + append(plainTextQuotedContent) + this.selection = TextRange.Zero + } + + emitNewStateFromOperation(MainEvent.OnQuotedHtmlRemoved) + } + + private suspend fun onExpirationSet(expiration: Duration) { + return messageAttributesFacade.saveMessageExpiration( + userId = primaryUserId.first(), + messageId = currentMessageId(), + senderEmail = currentSenderEmail(), + expiration = expiration + ).fold( + ifLeft = { emitNewStateFromOperation(EffectsEvent.ErrorEvent.OnSetExpirationError) }, + ifRight = { emitNewStateFromOperation(CompositeEvent.SetExpirationDismissed(expiration)) } + ) + } + + private suspend fun onSetNewSender(sender: SenderUiModel) { + val userId = primaryUserId.first() + val newSender = SenderEmail(sender.email) + + attachmentsFacade.reEncryptAttachments( + userId = userId, + messageId = currentMessageId(), + previousSender = currentSenderEmail(), + newSender = newSender + ).onLeft { + Timber.e("Failed to re-encrypt attachments: $it") + handleReEncryptionFailed(userId, newSender, currentMessageId()) + } + + emitNewStateFromOperation(CompositeEvent.UserChangedSender(newSender)) + } + + private suspend fun onChangeSenderRequested() { + val addresses = addressesFacade.getSenderAddresses() + .getOrElse { + val event = when (it) { + Error.UpgradeToChangeSender -> EffectsEvent.ErrorEvent.OnSenderChangeFreeUserError + Error.FailedDeterminingUserSubscription, + Error.FailedGettingPrimaryUser -> EffectsEvent.ErrorEvent.OnSenderChangePermissionsError + } + + return emitNewStateFromOperation(event) + } + .map { SenderUiModel(it.email) } + + return emitNewStateFromOperation(CompositeEvent.SenderAddressesListReady(addresses)) + } + + private suspend fun onClearSendingError() { + messageSendingFacade.clearMessageSendingError(primaryUserId.first(), currentMessageId()).onLeft { + Timber.e("Failed to clear SendingError: $it") + } + } + + private suspend fun onCloseComposer() { + emitNewStateFromOperation(MainEvent.CoreLoadingToggled) + pendingStoreDraftJob?.join() + + val draftFields = currentDraftFields() + + if (shouldSkipSave(draftFields)) { + emitNewStateFromOperation(EffectsEvent.ComposerControlEvent.OnCloseRequest(hasDraftSaved = false)) + } else { + val userId = primaryUserId.first() + + draftFacade.stopContinuousUpload() + + draftFacade.storeDraft( + userId = primaryUserId.first(), + draftMessageId = currentMessageId(), + fields = draftFields, + action = currentDraftActionOrDefault() + ) + + draftFacade.forceUpload(userId, currentMessageId()) + emitNewStateFromOperation(EffectsEvent.ComposerControlEvent.OnCloseRequest(hasDraftSaved = true)) + } + } + + private suspend fun handleOnSendMessage() { + emitNewStateFromOperation(MainEvent.CoreLoadingToggled) + pendingStoreDraftJob?.join() + + val draftFields = currentDraftFields() + if (draftFields.haveBlankSubject()) { + return emitNewStateFromOperation(CompositeEvent.OnSendWithEmptySubject) + } + + val accessoriesState = composerStates.value.accessories + + if (accessoriesState.messageExpiresIn != Duration.ZERO) { + if (!accessoriesState.isMessagePasswordSet) { + val externalRecipients = recipientsStateManager.recipients.value.let { + messageParticipantsFacade.getExternalRecipients( + userId = primaryUserId.first(), + recipientsTo = draftFields.recipientsTo, + recipientsCc = draftFields.recipientsCc, + recipientsBcc = draftFields.recipientsBcc + ) + } + + if (externalRecipients.isNotEmpty()) { + return emitNewStateFromOperation( + EffectsEvent.SendEvent.OnSendExpiringToExternalRecipients(externalRecipients) + ) + } + } + } + + onSendMessage(draftFields, toggleLoading = false) + } + + private suspend fun onSendMessage(draftFields: DraftFields, toggleLoading: Boolean = true) { + if (toggleLoading) emitNewStateFromOperation(MainEvent.CoreLoadingToggled) + pendingStoreDraftJob?.join() + + draftFacade.stopContinuousUpload() + messageSendingFacade.sendMessage(primaryUserId.first(), currentMessageId(), draftFields) + + if (networkManager.isConnectedToNetwork()) { + emitNewStateFromOperation(EffectsEvent.SendEvent.OnSendMessage) + } else { + emitNewStateFromOperation(EffectsEvent.SendEvent.OnOfflineSendMessage) + } + } + + private suspend fun onStoreAttachments(uriList: List) { + attachmentsFacade.storeAttachments(primaryUserId.first(), currentMessageId(), currentSenderEmail(), uriList) + .onLeft { error -> + Timber.e("Error storing attachment - User flow - $error") + emitNewStateFromOperation(EffectsEvent.AttachmentEvent.Error(error)) + } + } + + private suspend fun onAttachmentsRemoved(attachmentId: AttachmentId) { + attachmentsFacade.deleteAttachment( + primaryUserId.first(), + currentMessageId(), + currentSenderEmail(), + attachmentId + ).onLeft { Timber.e("Failed to delete attachment: $it") } + } + + @Suppress("ReturnCount") + private suspend fun shouldSkipSave(draftFields: DraftFields): Boolean { + val accessoriesState = composerStates.value.accessories + val attachmentsState = composerStates.value.attachments + + if (accessoriesState != ComposerState.Accessories.initial() || + attachmentsState != ComposerState.Attachments.initial() + ) { + return false + } + + if (!draftFields.haveBlankSubject()) return false + + if (draftFields.haveBlankRecipients() && + draftFields.haveBlankSubject() && + draftFields.body.value.isEmpty() + ) { + return true + } + + if (draftFields.haveBlankRecipients()) { + val signatureBody = draftFacade.injectAddressSignature( + primaryUserId.first(), + DraftBody(""), + currentSenderEmail() + ).getOrNull()?.value + + return draftFields.body.value == signatureBody + } + + return false + } + + private fun currentMessageId() = mutableComposerStates.value.main.draftId + private fun currentSenderEmail() = SenderEmail(mutableComposerStates.value.main.senderUiModel.email) + private fun currentDraftActionOrDefault() = draftAction ?: DraftAction.Compose + private fun resolveDraftId() = savedStateHandle.get(ComposerScreen.DraftMessageIdKey) + ?: draftFacade.provideNewDraftId().id + + private suspend fun currentDraftFields() = withContext(defaultDispatcher) { + val (toParticipants, ccParticipants, bccParticipants) = + recipientsStateManager.recipients.value.toParticipantFields { recipient -> + messageParticipantsFacade.mapToParticipant(recipient) + } + + DraftFields( + currentSenderEmail(), + Subject(subjectTextField.text.toString().stripNewLines()), + DraftBody(bodyFieldText.text.toString()), + RecipientsTo(toParticipants), + RecipientsCc(ccParticipants), + RecipientsBcc(bccParticipants), + composerStates.value.main.quotedHtmlContent?.original + ) + } + + private fun emitNewStateFromOperation(event: ComposerStateEvent) { + mutableComposerStates.update { composerStateReducer.reduceNewState(it, event) } + } + + private fun String.stripNewLines() = this.replace("[\n\r]".toRegex(), " ") + + private fun logViewModelAction(action: ComposerAction2, message: String) { + Timber + .tag("ComposerViewModel") + .d("Action ${action::class.java.simpleName} ${System.identityHashCode(action)} - $message") + } + + @AssistedFactory + interface Factory { + + fun create(recipientsStateManager: RecipientsStateManager): ComposerViewModel2 + } +} + +private fun TextFieldState.replaceText(text: String, resetRange: Boolean = false) { + clearText() + edit { + append(text) + if (resetRange) selection = TextRange.Zero + } +} + +private fun SavedStateHandle.extractRecipient(): List? { + return get(ComposerScreen.SerializedDraftActionKey)?.deserialize() + .let { it as? DraftAction.ComposeToAddresses } + ?.let { + it.recipients.map { recipient -> + when { + EmailValidator.isValidEmail(recipient) -> RecipientUiModel.Valid(recipient) + else -> RecipientUiModel.Invalid(recipient) + } + } + } +} + +private fun isCreatingEmptyDraft(inputDraftId: String?, draftAction: DraftAction?): Boolean = + inputDraftId == null && (draftAction == null || draftAction is DraftAction.ComposeToAddresses) diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModel.kt new file mode 100644 index 0000000000..6b4726a246 --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModel.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.domain.coroutines.DefaultDispatcher +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.domain.repository.ContactsPermissionRepository +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.usecase.SortContactsForSuggestions +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel.Companion.maxContactAutocompletionCount +import ch.protonmail.android.mailcontact.domain.usecase.SearchContactGroups +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel(assistedFactory = RecipientsViewModel.Factory::class) +internal class RecipientsViewModel @AssistedInject constructor( + private val observePrimaryUserId: ObservePrimaryUserId, + private val searchContacts: SearchContacts, + private val searchContactGroups: SearchContactGroups, + private val searchDeviceContacts: SearchDeviceContacts, + private val sortContactsForSuggestions: SortContactsForSuggestions, + private val contactsPermissionRepository: ContactsPermissionRepository, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @Assisted val recipientsStateManager: RecipientsStateManager +) : ViewModel() { + + private val searchTerm = MutableStateFlow("") + + private val mutableContactSuggestionsFieldFlow = MutableStateFlow(null) + val contactSuggestionsFieldFlow = mutableContactSuggestionsFieldFlow.asStateFlow() + + val contactsSuggestions = observeContactsSuggestions().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val contactsPermissionDenied = contactsPermissionRepository.observePermissionDenied() + .map { it.getOrNull() == true } + + fun denyContactsPermission() { + viewModelScope.launch { contactsPermissionRepository.trackPermissionDenied() } + } + + fun updateSearchTerm(term: String, contactSuggestionsField: ContactSuggestionsField) { + searchTerm.update { term } + mutableContactSuggestionsFieldFlow.update { contactSuggestionsField } + } + + fun updateRecipients(values: List, type: ContactSuggestionsField) { + recipientsStateManager.updateRecipients(values, type) + } + + fun closeSuggestions() { + mutableContactSuggestionsFieldFlow.update { null } + } + + @OptIn(FlowPreview::class) + private fun observeContactsSuggestions() = combine( + primaryUserId(), + searchTerm + ) { userId, searchTerm -> + userId to searchTerm + } + .flatMapLatest { (userId, searchTerm) -> + if (searchTerm.isBlank()) return@flatMapLatest flowOf(emptyList()) + + combine( + searchContacts(userId, searchTerm, onlyMatchingContactEmails = true), + searchContactGroups(userId, searchTerm) + ) { contactsResult, contactGroupsResult -> + val deviceContacts = withContext(defaultDispatcher) { + searchDeviceContacts(searchTerm).getOrNull() ?: emptyList() + } + + sortContactsForSuggestions( + contactsResult.getOrNull() ?: emptyList(), + deviceContacts, + contactGroupsResult.getOrNull() ?: emptyList(), + maxContactAutocompletionCount + ) + }.debounce(SuggestionsDebounce) + } + + private fun primaryUserId() = observePrimaryUserId.invoke().filterNotNull() + + @AssistedFactory + interface Factory { + + fun create(recipientsStateManager: RecipientsStateManager): RecipientsViewModel + } + + private companion object { + + val SuggestionsDebounce = 200.milliseconds + } +} diff --git a/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModel.kt b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModel.kt new file mode 100644 index 0000000000..bb6f1f3caf --- /dev/null +++ b/mail-composer/presentation/src/main/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModel.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessagePasswordAction +import ch.protonmail.android.mailcomposer.presentation.model.MessagePasswordOperation +import ch.protonmail.android.mailcomposer.presentation.model.SetMessagePasswordState +import ch.protonmail.android.mailcomposer.presentation.reducer.SetMessagePasswordReducer +import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import me.proton.core.util.kotlin.deserializeOrNull +import javax.inject.Inject + +@HiltViewModel +class SetMessagePasswordViewModel @Inject constructor( + private val deleteMessagePassword: DeleteMessagePassword, + private val observeMessagePassword: ObserveMessagePassword, + private val reducer: SetMessagePasswordReducer, + private val saveMessagePassword: SaveMessagePassword, + observePrimaryUserId: ObservePrimaryUserId, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val primaryUserId = observePrimaryUserId().filterNotNull() + private val inputParams: SetMessagePasswordScreen.InputParams? = savedStateHandle.get( + SetMessagePasswordScreen.InputParamsKey + )?.deserializeOrNull() + + private val mutableState = MutableStateFlow(SetMessagePasswordState.Loading) + val state: StateFlow = mutableState.asStateFlow() + + init { + initializeScreen() + } + + fun submit(action: MessagePasswordOperation.Action) = when (action) { + is MessagePasswordOperation.Action.ValidatePassword -> onValidatePassword(action.password) + is MessagePasswordOperation.Action.ValidateRepeatedPassword -> + onValidateRepeatedPassword(action.password, action.repeatedPassword) + is MessagePasswordOperation.Action.ApplyPassword -> onApplyPassword(action.password, action.passwordHint) + is MessagePasswordOperation.Action.UpdatePassword -> onUpdatePassword(action.password, action.passwordHint) + is MessagePasswordOperation.Action.RemovePassword -> onRemovePassword() + } + + private fun onValidatePassword(password: String) { + viewModelScope.launch { + val hasMessagePasswordError = password.length !in MIN_PASSWORD_LENGTH..MAX_PASSWORD_LENGTH + emitNewStateFrom(MessagePasswordOperation.Event.PasswordValidated(hasMessagePasswordError)) + } + } + + private fun onValidateRepeatedPassword(password: String, repeatedPassword: String) { + viewModelScope.launch { + val hasRepeatedMessagePasswordError = password != repeatedPassword + emitNewStateFrom(MessagePasswordOperation.Event.RepeatedPasswordValidated(hasRepeatedMessagePasswordError)) + } + } + + private fun onApplyPassword(password: String, passwordHint: String?) { + viewModelScope.launch { + inputParams?.let { inputParams -> + saveMessagePassword( + primaryUserId.first(), + inputParams.messageId, + inputParams.senderEmail, + password, + passwordHint + ) + emitNewStateFrom(MessagePasswordOperation.Event.ExitScreen) + } + } + } + + private fun onUpdatePassword(password: String, passwordHint: String?) { + viewModelScope.launch { + inputParams?.let { inputParams -> + saveMessagePassword( + primaryUserId.first(), + inputParams.messageId, + inputParams.senderEmail, + password, + passwordHint, + SaveMessagePasswordAction.Update + ) + emitNewStateFrom(MessagePasswordOperation.Event.ExitScreen) + } + } + } + + private fun onRemovePassword() { + viewModelScope.launch { + inputParams?.let { + deleteMessagePassword(primaryUserId.first(), it.messageId) + emitNewStateFrom(MessagePasswordOperation.Event.ExitScreen) + } + } + } + + private fun initializeScreen() { + viewModelScope.launch { + inputParams?.let { + val messagePassword = observeMessagePassword(primaryUserId.first(), inputParams.messageId).first() + emitNewStateFrom(MessagePasswordOperation.Event.InitializeScreen(messagePassword)) + } + } + } + + private suspend fun emitNewStateFrom(event: MessagePasswordOperation.Event) { + mutableState.emit(reducer.newStateFrom(mutableState.value, event)) + } + + companion object { + const val MIN_PASSWORD_LENGTH = 4 + const val MAX_PASSWORD_LENGTH = 21 + } +} diff --git a/mail-composer/presentation/src/main/res/values-b+es+419/strings.xml b/mail-composer/presentation/src/main/res/values-b+es+419/strings.xml new file mode 100644 index 0000000000..158b09b724 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,106 @@ + + + + De: + Para: + CC: + CCO: + Asunto + Escribir un correo + Cerrar el editor + Enviar mensaje + Mostrar los otros destinatarios + Dirección de correo inválida + La dirección del remitente no es válida + Cambiar el remitente + Necesita una suscripción de pago a Proton Mail para cambiar la dirección del remitente. + No se puede cambiar el remitente porque no pudo obtener la suscripción del usuario. Intente otra vez. + Error al guardar la dirección del remitente + Error al guardar el cuerpo de este borrador + Destinatario(s) duplicado(s) eliminado(s) + Error al guardar el asunto + Error al guardar los destinatarios de este borrador + El contenido de este borrador no está disponible en este momento. Editarlo sobrescribirá cualquier contenido preexistente. + Agregar archivo adjunto + Importar desde… + No se pudo cargar la información del mensaje + Mensaje original + El %s, %s <%s> escribió: + Aceptar + Se alcanzó el límite de archivos adjuntos. + El límite del tamaño para los archivos adjuntos es de %1$s. + Error al volver a cifrar el archivo adjunto. Por seguridad se eliminaron todos los archivos adjuntos. + No se encontró el archivo adjunto. + No se puede cargar el mensaje original del borrador. + No se puede almacenar el archivo adjunto. Inténtelo de nuevo. + Error al cargar la última versión de este borrador. Cualquier cambio sobrescribirá su contenido. + Agregar una contraseña + Establecer tiempo de expiración + Expiración del mensaje + Establecer + Ninguna + 1 hora + 1 día + 3 días + 1 semana + Personalizar + Error al establecer la fecha de expiración + + No se admiten los mensajes con fecha de expiración. + Recomendamos configurar una contraseña para los siguientes destinatarios: + Cancelar + Enviar de todos modos + + Aviso de envío + El envío de mensajes desde @pm.me es una función de pago. El mensaje será enviado desde la dirección por defecto. + La selección de dirección de envío está desactivada. El mensaje se enviará desde la dirección de correo por defecto. + No se pudo obtener la dirección del remitente original. Su mensaje se enviará desde Su dirección de correo por defecto. + Aceptar + + Enviar + ¿Enviar el mensaje sin asunto? + + No + + Error de envío + Cerrar + No se puede enviar el correo electrónico a la dirección indicada debido a la siguiente razón: + No hay claves de confianza: %s + La dirección no existe: %s + Cifrar mensaje + Establezca una contraseña para cifrar este mensaje para usuarios que no sean de Proton Mail. + Más información + Contraseña del mensaje + Repetir contraseña + Pista de la contraseña (opcional) + Longitud de 4 a 21 caracteres + La contraseña debe tener entre 4 y 21 caracteres + Las contraseñas deben coincidir + Las contraseñas no coinciden. + Mostrar contraseña + Ocultar contraseña + Aplicar contraseña + Guardar los cambios + Eliminar la contraseña + Responder entre líneas + + %d miembro + %d miembros + + diff --git a/mail-composer/presentation/src/main/res/values-be/strings.xml b/mail-composer/presentation/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..53b03db6c8 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-be/strings.xml @@ -0,0 +1,108 @@ + + + + Ад: + Каму: + Копія: + Схаваная копія: + Тэма + Напісаць ліст + Закрыць рэдактар + Адправіць ліст + Паказаць іншых удзельнікаў + Памылковы адрас электроннай пошты + Памылковы адрас адпраўніка + Змяніць адпраўніка + Неабходна платная падпіска Proton Mail, каб змяніць адрас адпраўніка + Немагчыма змяніць адпраўніка, бо не ўдаецца атрымаць падпіску карыстальніка. Паспрабуйце яшчэ раз. + Адрас адпраўніка не захаваны + Тэкст чарнавіка ліста не захаваны + Дубліраваныя атрымальнік(і) выдалены + Радок тэмы не захаваны + Збой захавання атрымальнікаў гэтага чарнавіка + Змесціва гэтага чарнавіка недаступна ў цяперашні час. Рэдагаванне перавызначыць любое змесціва, якое было створана да гэтага. + Дадаць далучэнні + Імпартаваць з… + Немагчыма загрузіць звесткі паведамлення + Арыгінальнае паведамленне + %s, %s <%s> напісаў(-ла): + OK + Перавышана абмежаванне па колькасці далучэнняў + Максімальны памер далучэння складае: %1$s + Збой паўторнага шыфравання. У мэтах бяспекі ўсе далучэнні былі выдалены. + Далучэнне ня знойдзена. + Немагчыма загрузіць арыгінальнае паведамленне чарнавіка. + Немагчыма захаваць далучэнне. Паспрабуйце яшчэ раз. + Збой загрузкі апошняй версіі гэтага чарнавіка. Любыя змены перавызначаць яго змесцівам. + Дадаць пароль + Задаць тэрмін дзеяння + Тэрмін дзеяння паведамлення + Задаць + Няма + 1 гадзіна + 1 дзень + 3 дні + 1 тыдзень + Карыстальніцкае + Збой наладжвання тэрміну дзеяння + + Тэрмін дзеяння не падтрымліваецца + Замест гэтага мы рэкамендуем наладзіць пароль для наступных атрымальнікаў: + Скасаваць + Усё роўна адправіць + + Адпраўка апавяшчэння + Адпраўка паведамленняў з адраса @pm.me з\'яўляецца платнай функцыяй. Ваша паведамленне будзе адпраўлена з вашага прадвызначанага адраса. + Выбраны адрас адпраўніка адключаны. Ваша паведамленне будзе адпраўлена з вашага прадвызначанага адраса. + Немагчыма атрымаць арыгінальны адрас адпраўніка. Ваша паведамленне будзе адпраўлена з вашага прадвызначанага адраса. + OK + + Напісаць + Адправіць паведамленне без тэмы? + Так + Не + + Памылка адпраўкі + Закрыць + Ваш ліст немагчыма адправіць на ўведзены адрас па наступнай прычыне: + Адсутнічаюць давераныя ключы: %s + Адрас не існуе: %s + Зашыфраваць паведамленне + Задаць пароль, каб зашыфраваць гэта паведамленне для тых, хто не карыстаецца Proton Mail. + Даведацца больш + Пароль паведамлення + Паўтарыце пароль + Падказка да пароля (неабавязкова) + Ад 4 да 21 сімвала + Ваш пароль павінен мець даўжыню ад 4 да 21 сімвала + Паролі павінны супадаць + Паролі не супадаюць + Паказаць пароль + Схаваць пароль + Ужыць пароль + Захаваць змены + Выдаліць пароль + Адказаць з цытаваннем + + %d удзельнік + %d удзельнікі + %d удзельнікаў + %d удзельнікаў + + diff --git a/mail-composer/presentation/src/main/res/values-ca/strings.xml b/mail-composer/presentation/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..3f198426a2 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ca/strings.xml @@ -0,0 +1,106 @@ + + + + De: + Per a: + Cc: + CCO: + Assumpte + Escriure un correu electrònic + Tancar l\'editor + Envia missatge + Mostra els altres destinataris + L\'adreça de correu electrònic no és vàlida + L\'adreça del remitent no és vàlida + Canvia el remitent + Necessiteu una subscripció de Proton Mail de pagament per canviar l\'adreça del remitent. + No es pot canviar el remitent perquè no s\'ha pogut obtenir la subscripció de l\'usuari. Torna-ho a provar. + No s\'ha pogut desar l\'adreça del remitent. + No s\'ha pogut desar el cos de l\'esborrany. + S\'han eliminat un o més destinataris duplicats. + L\'assumpte no s\'ha desat + S\'ha produït un error en desar els destinataris de l\'esborrany. + El contingut d\'aquest esborrany no està disponible en aquest moment. L\'edició substituirà qualsevol contingut preexistent. + Afegiu fitxers adjunts + Importar des de… + No s\'ha pogut carregar la informació del missatge. + Missatge original + El %s, %s <%s> va escriure: + D\'acord + S\'ha assolit el límit de fitxers adjunts. + El límit de mida dels adjunts és %1$s. + No s\'ha pogut tornar a xifrar el fitxer adjunt. Per motius de seguretat, s\'han eliminat tots els fitxers adjunts. + No s\'ha trobat l\'adjunt. + No s\'ha pogut carregar l\'esborrany original del missatge. + No s\'ha pogut emmagatzemar l\'adjunt. Torneu-ho a provar. + La darrera versió d\'aquest esborrany no s\'ha pogut carregar. Qualsevol canvi anul·larà el seu contingut. + Afegeix una contrasenya + Establiu el temps de caducitat + Caducitat del missatge + Estableix + Cap + 1 hora + 1 dia + 3 dies + 1 setmana + Personalitza + No s\'ha pogut establir el temps de caducitat. + + Caducitat no admesa + Recomanem configurar una contrasenya pels destinataris següents: + Cancel·la + Envia de totes maneres + + Avís d\'enviament + L\'enviament de missatges des de @pm.me és una característica de pagament. El missatge s\'enviarà des de la vostra adreça per defecte. + L\'adreça del remitent seleccionada està desactivada. El missatge s\'enviarà des de la vostra adreça per defecte. + No s\'ha pogut obtenir l\'adreça original del remitent. El missatge s\'enviarà des de l\'adreça per defecte. + D\'acord + + Redacta + Enviar missatge sense assumpte? + + No + + Error d\'enviament + Tanca + El correu electrònic no es pot enviar a l\'adreça introduïda pel motiu següent: + No hi ha claus de confiança: %s + L\'adreça no existeix: %s + Xifra el missatge + Definiu una contrasenya per xifrar aquest missatge per a usuaris que no siguin de Proton Mail. + Més informació + Contrasenya del missatge + Repeteix la contrasenya + Pista de contrasenya (opcional) + Longitud de 4 a 21 caràcters + La contrasenya ha de tenir entre 4 i 21 caràcters + Les contrasenyes han de coincidir + Les contrasenyes no coincideixen. + Mostra la contrasenya + Amaga la contrasenya + Aplica la contrasenya + Desa els canvis + Esborra la contrasenya + Respon en línia + + %d membre + %d membres + + diff --git a/mail-composer/presentation/src/main/res/values-cs/strings.xml b/mail-composer/presentation/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..31e64b3b6e --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-cs/strings.xml @@ -0,0 +1,108 @@ + + + + Od: + Komu: + Kopie: + Skrytá kopie: + Předmět + Napsat e-mail + Zavřít editor + Odeslat zprávu + Zobrazit ostatní příjemce + E-mailová adresa je neplatná + Adresa odesílatele je neplatná + Změnit odesílatele + Pro změnu adresy odesílatele potřebujete předplatné Proton Mail + Nelze změnit odesílatele, protože selhalo získání uživatelského předplatného. Zkuste to znovu. + Selhalo uložení adresy odesílatele + Selhalo uložení konceptu těla e-mailu + Duplicitní příjemci odebráni + Selhalo uložení předmětu + Uložení příjemců tohoto konceptu selhalo + Obsah tohoto návrhu není v tuto chvíli k dispozici. Úpravy přepíší veškerý již existující obsah. + Přidat přílohy + Importovat z… + Nelze načíst informace zprávy + Původní zpráva + %s, %s <%s> napsal/a: + OK + Byl dosažen limit velikosti příloh + Velikost příloh je omezena na %1$s + Opětovné zašifrování přílohy selhalo. Z bezpečnostních důvodů byly všechny přílohy odebrány. + Příloha nenalezena. + Nelze načíst původní koncept zprávy. + Nelze uložit přílohu. Zkuste to prosím znovu. + Nejnovější verzi tohoto návrhu se nepodařilo načíst. Jakákoli změna přepíše její obsah. + Přidat heslo + Nastavit dobu platnosti + Vypršení zprávy + Nastavit + Neomezeně + 1 hodina + 1 den + 3 dny + 1 týden + Vlastní + Nastavení doby platnosti selhalo + + Vypršení platnosti není podporováno + Místo toho doporučujeme nastavit heslo pro následující příjemce: + Zpět + Přesto odeslat + + Zaslání upozornění + Odesílání zpráv z @pm.me je placená funkce. Vaše zpráva bude odeslána z vaší výchozí adresy. + Vybraná adresa odesílatele byla deaktivována. Zpráva bude odeslána z vaší výchozí adresy. + Původní adresu odesílatele se nepodařilo získat. Vaše zpráva bude odeslána z vaší výchozí adresy. + OK + + Nová zpráva + Odeslat zprávu bez předmětu? + Ano + Ne + + Chyba při odesílání + Zavřít + Váš e-mail nemůže být odeslán na zadanou e-mailovou adresu z následujícího důvodu: + Neexistují žádné důvěryhodné klíče: %s + Adresa neexistuje: %s + Šifrovat zprávu + Nastavit heslo pro šifrování zprávy uživatelům bez Proton Mailu. + Více informací + Heslo zprávy + Zopakujte heslo + Nápověda k heslu (nepovinné) + 4 až 21 znaků + Heslo musí mít délku 4 až 21 znaků + Hesla se musí shodovat + Hesla se neshodují + Zobrazit heslo + Skrýt heslo + Použít heslo + Uložit změny + Odstranit heslo + Odpovědět v řádku + + %d člen + %d členové + %d členů + %d členů + + diff --git a/mail-composer/presentation/src/main/res/values-da/strings.xml b/mail-composer/presentation/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..3a11ae9491 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-da/strings.xml @@ -0,0 +1,106 @@ + + + + Fra: + Til: + CC: + BCC: + Emne + Skriv e-mail + Luk skrivevinduet + Send besked + Vis andre modtagere + E-mailadresse er ugyldig + Afsenderadresse er ugyldig + Skift afsender + Du skal have et betalt Proton Mail-abonnement for at ændre afsenderadressen + Kan ikke ændre afsender, da hentning af brugerens abonnement mislykkedes. Prøv igen. + Lagring af denne kladdes afsenderadresse mislykkedes + Opbevaring af denne kladdes indholdstekst mislykkedes + Dublet-modtager(e) fjernet + Lagring af denne kladdes emne mislykkedes + Lagring af denne kladdes modtagere mislykkedes + Indholdet af denne kladde er ikke tilgængeligt på nuværende tidspunkt. Redigering vil overskrive alt allerede eksisterende indhold. + Tilføj vedhæftninger + Importér fra… + Kunne ikke indlæse beskedens oplysninger + Oprindelig besked + %s, %s <%s> skrev: + OK + Grænse for vedhæftning nået + Størrelsesgrænsen for vedhæftede filer er %1$s + Rekryptering af vedhæftet fil mislykkedes. Af sikkerhedsgrunde blev alle vedhæftede filer fjernet. + Attachment not found. + Unable to load the original draft message. + Unable to store the attachment. Please try again. + Den seneste version af denne kladde kunne ikke indlæses. Enhver ændring vil tilsidesætte dens indhold. + Tilføj adgangskode + Indstil udløbstidspunkt + Beskedudløb + Indstil + Ingen + 1 time + 1 dag + 3 dage + 1 uge + Brugerdefineret + Indstilling af udløbstid mislykkedes + + Udløb ikke understøttet + Vi anbefaler at oprette en adgangskode i stedet for følgende modtagere: + Annullér + Send alligevel + + Sender notifikation + Afsendelse af beskeder fra @pm.me er en betalt funktion. Din besked vil blive sendt fra din standardadresse. + Den valgte afsenderadresse er deaktiveret. Din besked vil blive sendt fra din standardadresse. + The original sender address could not be obtained. Your message will be sent from your default address. + Ok + + Skriv + Afsend besked uden emneindhold? + Ja + Nej + + Afsendelsesfejl + Luk + Din e-mail kan ikke sendes til den indtastede adresse af følgende grund: + Der er ingen betroede nøgler: %s + Adresse findes ikke: %s + Krypter besked + Indstil en adgangskode for at kryptere denne besked til brugere, som ikke anvender Proton Mail. + Få mere at vide + Beskedadgangskode + Gentag adgangskode + Adgangskodetip (valgfrit) + 4 til 21 tegn lang + Adgangskoden skal være mellem 4 og 21 tegn lang + Adgangskoder skal være ens + Adgangskoder matcher ikke + Vis adgangskode + Skjul adgangskode + Anvend adgangskode + Gem ændringer + Fjern adgangskode + Besvar in-line + + %d medlem + %d medlemmer + + diff --git a/mail-composer/presentation/src/main/res/values-de/strings.xml b/mail-composer/presentation/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..187dd96da8 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-de/strings.xml @@ -0,0 +1,106 @@ + + + + Von: + An: + CC: + BCC: + Betreff + E-Mail verfassen + Verfassen-Fenster schließen + Nachricht senden + Andere Empfänger anzeigen + E-Mail-Adresse ist ungültig + Absenderadresse ist ungültig + Absender ändern + Du benötigst ein kostenpflichtiges Proton Mail Abonnement, um die Absenderadresse zu ändern + Fehler beim Ändern des Absenders, da das Abonnement des Benutzers nicht abgerufen werden konnte. Bitte versuche es erneut. + Absenderadresse wurde nicht gespeichert + E-Mail-Entwurf wurde nicht gespeichert + Doppelte Empfänger entfernt + Betreffzeile wurde nicht gespeichert + Das Speichern der Empfänger dieses Entwurfs ist fehlgeschlagen + Der Inhalt dieses Entwurfs ist zu diesem Zeitpunkt nicht verfügbar. Durch die Bearbeitung wird der bereits vorhandene Inhalt überschrieben. + Anhänge hinzufügen + Importieren aus… + Die Informationen der Nachricht konnten nicht geladen werden. + Ursprüngliche Nachricht + Am %s schrieb %s <%s>: + OK + Limit für Anhänge erreicht + Die maximale Größe für Anhänge beträgt %1$s + Die erneute Verschlüsselung des Anhangs ist fehlgeschlagen. Aus Sicherheitsgründen wurden alle Anhänge entfernt. + Anhang nicht gefunden. + Der ursprüngliche Nachrichtenentwurf kann nicht geladen werden. + Der Anhang konnte nicht gespeichert werden. Bitte versuche es erneut. + Die letzte Version dieses Entwurfs konnte nicht geladen werden. Jede Änderung wird den Inhalt überschreiben. + Passwort hinzufügen + Ablaufdatum festlegen + Gültigkeitsdauer der Nachricht + Festlegen + Keine + 1 Stunde + 1 Tag + 3 Tage + 1 Woche + Benutzerdefiniert + Fehler beim Festlegen der Ablaufzeit. + + Ablaufdatum nicht unterstützt + Wir empfehlen, stattdessen für die folgenden Empfänger ein Passwort einzurichten: + Abbrechen + Trotzdem senden + + Mitteilung wird gesendet + Das Senden von Nachrichten von einer @pm.me-Adresse ist eine kostenpflichtige Funktion. Deine Nachricht wird von deiner Standardadresse gesendet + Die ausgewählte Absenderadresse ist deaktiviert. Deine Nachricht wird von deiner Standardadresse aus gesendet. + Die ursprüngliche Absenderadresse wurde nicht gefunden. Deine Nachricht wird von deiner Standardadresse aus gesendet. + OK + + Verfassen + Nachricht ohne Betreff senden? + Ja + Nein + + Sendefehler + Schließen + Deine E-Mail kann aus folgendem Grund nicht an die angegebene Adresse versendet werden: + Es gibt keine vertrauenswürdigen Schlüssel: %s + Adresse existiert nicht: %s + Nachricht verschlüsseln + Lege ein Passwort zum Verschlüsseln dieser Nachricht für nicht-Proton Mail-Benutzer fest. + Mehr erfahren + Passwort der Nachricht + Passwort wiederholen + Passworthinweis (optional) + 4-21 Zeichen lang + Das Passwort muss zwischen 4 und 21 Zeichen lang sein. + Die Passwörter müssen übereinstimmen + Die Passwörter stimmen nicht überein + Passwort anzeigen + Passwort ausblenden + Passwort übernehmen + Änderungen speichern + Passwort entfernen + Eingebettet antworten + + %d Mitglied + %d Mitglieder + + diff --git a/mail-composer/presentation/src/main/res/values-el/strings.xml b/mail-composer/presentation/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..229e7a0a23 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-el/strings.xml @@ -0,0 +1,106 @@ + + + + Από: + Προς: + Κοιν: + Κρυφή κοιν: + Θέμα + Σύνταξη μηνύματος + Κλείσιμο συντάκτη + Αποστολή μηνύματος + Εμφάνιση άλλων παραληπτών + Η διεύθυνση email δεν είναι έγκυρη + Μη έγκυρη διεύθυνση αποστολέα + Αλλαγή αποστολέα + Χρειάζεστε μια πληρωμένη συνδρομή Proton Mail για να αλλάξετε τη διεύθυνση αποστολέα + Δεν είναι δυνατή η αλλαγή αποστολέα καθώς απέτυχε η λήψη της συνδρομής του χρήστη. Δοκιμάστε ξανά. + Αποτυχία αποθήκευσης της διεύθυνσης αποστολέα + Αποτυχία αποθήκευσης του περιεχομένου του προχείρου + Οι διπλότυποι παραλήπτες καταργήθηκαν + Αποτυχία αποθήκευσης θέματος του προχείρου + Η αποθήκευση των παραληπτών αυτού του πρόχειρου απέτυχε + Το περιεχόμενο αυτού του πρόχειρου δεν είναι διαθέσιμο προς το παρόν. Η επεξεργασία θα αντικαταστήσει τυχόν προϋπάρχον περιεχόμενο. + Προσθήκη συνημμένων + Εισαγωγή από… + Δεν ήταν δυνατή η φόρτωση των πληροφοριών του μηνύματος + Αρχικό Μήνυμα + Την %s, %s ο/η <%s> έγραψε: + ΟΚ + Εξάντληση ορίου συνημμένων + Το όριο μεγέθους για τα συνημμένα είναι %1$s + Απέτυχε η εκ νέου κρυπτογράφηση του συνημμένου. Για λόγους ασφαλείας, όλα τα συνημμένα καταργήθηκαν. + Το συνημμένο δεν βρέθηκε. + Αποτυχία φόρτωσης του πρωτότυπου πρόχειρου μηνύματος. + Αδυναμία αποθήκευσης συνημμένου. Δοκιμάστε ξανά. + Απέτυχε η φόρτωση της τελευταίας έκδοσης αυτού του προχείρου. Οποιαδήποτε αλλαγή θα υπερισχύει του περιεχομένου της. + Προσθήκη κωδικού πρόσβασης + Ορισμός χρόνου λήξης + Λήξη μηνύματος + Ορισμός + Κανένα + 1 ώρα + 1 ημέρα + 3 ημέρες + 1 εβδομάδα + Προσαρμοσμένο + Η ρύθμιση του χρόνου λήξης απέτυχε + + Μη υποστηριζόμενη ημ/νία λήξης + Συνιστούμε να ορίσετε έναν κωδικό πρόσβασης για τους ακόλουθους παραλήπτες: + Ακύρωση + Αποστολή ούτως ή άλλως + + Αποστολή ειδοποίησης + Η αποστολή μηνυμάτων από διεύθυνση @pm.me είναι μια λειτουργία επί πληρωμή. Το μήνυμά σας θα σταλεί από την προεπιλεγμένη σας διεύθυνση. + Η επιλεγμένη διεύθυνση αποστολέα είναι απενεργοποιημένη. Το μήνυμά σας θα σταλεί από την προεπιλεγμένη διεύθυνσή σας. + Η αρχική διεύθυνση αποστολέα δεν βρέθηκε. Το μήνυμά σας θα σταλεί από την προεπιλεγμένη διεύθυνσή σας. + ΟΚ + + Σύνταξη + Αποστολή μηνύματος χωρίς θέμα; + Ναι + Όχι + + Σφάλμα αποστολής + Κλείσιμο + Το μήνυμα σας δεν μπορεί να σταλεί στη διεύθυνση ηλεκτρονικού ταχυδρομείου που καταχωρήσατε για τον ακόλουθο λόγο: + Δεν υπάρχουν Αξιόπιστα Κλειδιά: %s + Η διεύθυνση δεν υπάρχει: %s + Κρυπτογράφηση μηνύματος + Ορίστε έναν κωδικό για την κρυπτογράφηση αυτού του μηνύματος για τους χρήστες που δεν χρησιμοποιούν το ProtonMail. + Μάθετε περισσότερα + Κωδικός πρόσβασης μηνύματος + Επανάληψη κωδικού + Υπόδειξη κωδικού πρόσβασης (προαιρετικό) + μήκος 4 έως 21 χαρακτήρες + Ο κωδικός πρόσβασης πρέπει να αποτελείται από 4 έως 21 χαρακτήρες + Οι κωδικοί πρόσβασης πρέπει να ταυτίζονται + Οι κωδικοί πρόσβασης δεν ταυτίζονται + Εμφάνιση κωδικού πρόσβασης + Απόκρυψη κωδικού πρόσβασης + Εφαρμογή κωδικού πρόσβασης + Αποθήκευση αλλαγών + Αφαίρεση κωδικού πρόσβασης + Απάντηση μέσα στην γραμμή + + %d μέλος + %d μέλη + + diff --git a/mail-composer/presentation/src/main/res/values-es-rES/strings.xml b/mail-composer/presentation/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..6cb4e5c618 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,106 @@ + + + + De: + Para: + CC: + CCO: + Asunto + Escribir un correo + Cerrar el editor + Enviar mensaje + Mostrar los otros destinatarios + La dirección de correo electrónico no es válida. + La dirección del remitente no es válida. + Cambiar el remitente + Necesitas una suscripción de pago a Proton Mail para cambiar la dirección del remitente. + No se puede cambiar el remitente porque no se ha podido obtener la suscripción del usuario. Intenta otra vez. + Error al guardar la dirección del remitente + Error al guardar el cuerpo de este borrador + Se han borrado un o más destinatarios duplicados. + Error al guardar el asunto + Error al guardar los destinatarios de este borrador + El contenido de este borrador no está disponible en este momento. Editarlo sobrescribirá cualquier contenido preexistente. + Añadir archivos adjuntos + Importar desde… + No se ha podido cargar la información del mensaje. + Mensaje original + El %s, %s <%s> ha escrito: + Aceptar + Se ha alcanzado el límite de archivos adjuntos. + El límite del tamaño para los archivos adjuntos es de %1$s. + Error al volver a cifrar el archivo adjunto. Por seguridad se han eliminado todos los archivos adjuntos. + No se ha encontrado el archivo adjunto. + No se puede cargar el mensaje original del borrador. + No se puede almacenar el archivo adjunto. Inténtalo de nuevo. + Error al cargar la última versión de este borrador. Cualquier cambio sobrescribirá su contenido. + Añadir una contraseña + Establecer el tiempo de expiración + Expiración del mensaje + Establecer + Ninguna + 1 hora + 1 día + 3 días + 1 semana + Personalizar + Error al establecer la fecha de expiración + + No se admiten los mensajes con fecha de expiración. + Recomendamos configurar una contraseña para los siguientes destinatarios: + Cancelar + Enviar de todos modos + + Notificación de envío + El envío de mensajes desde @pm.me es una función de pago. Tu mensaje será enviado desde tu dirección por defecto. + La selección de dirección de envío está desactivada. Tu mensaje se enviará desde tu dirección de correo por defecto. + No se ha podido obtener la dirección del remitente original. Tu mensaje se enviará desde tu dirección de correo por defecto. + Aceptar + + Enviar + ¿Enviar el mensaje sin asunto? + + No + + Error de envío + Cerrar + No se puede enviar el correo electrónico a la dirección indicada debido a la siguiente razón: + No hay claves de confianza: %s + La dirección no existe: %s + Cifrar el mensaje + Establece una contraseña para cifrar este mensaje para usuarios que no sean de Proton Mail. + Más información + Contraseña del mensaje + Repetir la contraseña + Pista de la contraseña (opcional) + 4 a 21 caracteres + La contraseña debe tener entre 4 y 21 caracteres. + Las contraseñas deben coincidir. + Las contraseñas no coinciden. + Mostrar la contraseña + Ocultar la contraseña + Aplicar la contraseña + Guardar los cambios + Eliminar la contraseña + Responder en línea + + %d miembro + %d miembros + + diff --git a/mail-composer/presentation/src/main/res/values-fi/strings.xml b/mail-composer/presentation/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..b00cc6fe73 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-fi/strings.xml @@ -0,0 +1,106 @@ + + + + Lähettäjä: + Vastaanottaja: + Kopio: + Piilokopio: + Aihe + Kirjoita viesti + Sulje viestin kirjoitus + Lähetä viesti + Näytä muut vastaanottajat + Sähköpostiosoite on virheellinen + Lähettäjän osoite on virheellinen + Vaihda lähettäjä + Tarvitset maksullisen Proton Mail -tilauksen muuttaaksesi lähettäjän osoitetta + Lähettäjää ei voida vaihtaa, koska käyttäjän tilaustietojen nouto epäonnistui. Yritä uudelleen. + Luonnoksen lähetysosoitteen tallennus epäonnistui + Luonnoksen sisällön tallennus epäonnistui + Vastaanottajien kaksoiskappaleet poistettiin + Luonnoksen otsikon tallennus epäonnistui + Luonnoksen vastaanottajien tallennus epäonnistui + Luonnoksen sisältö ei ole tällä hetkellä käytettävissä ja sen muokkaus korvaa kaiken olemassa olevan sisällön. + Lisää liitteitä + Tuo lähteestä… + Viestin tietoja ei voitu ladata + Alkuperäinen viesti + %s, %s <%s> kirjoitti: + OK + Liiterajoitus on saavutettu + Liitteiden enimmäiskoko on %1$s + Liitteen uudelleensalaus epäonnistui ja kaikki liitteet poistettiin tietoturvasyistä. + Liitettä ei löydy. + Alkuperäisen luonnosviestin lataaminen epäonnistui. + Liitteen tallentaminen epäonnistui. Yritä uudelleen. + Tämän luonnoksen uusimman version lataus epäonnistui. Kaikki muutokset korvaavat sen sisällön. + Lisää salasana + Aseta tuhoutumisaika + Viestin tuhoutuminen + Aseta + Ei määritetty + Yksi tunti + Yksi päivä + Kolme päivää + Yksi viikko + Mukautettu + Tuhoutumisajan asettaminen epäonnistui + + Tuhoutumista ei tueta + Seuraaville vastaanottajille on sen sijaan suositeltavaa asettaa salasana: + Peruuta + Lähetä silti + + Lähetysilmoitus + Viestien lähetys @pm.me-osoitteesta on maksullinen ominaisuus. Viestisi lähetetään oletusosoitteestasi. + Valittu lähetysosoite on poistettu käytöstä. Viestisi lähetetään oletusosoitteestasi. + Alkuperäistä lähettäjän osoitetta ei voitu hakea. Viestisi lähetetään oletusosoitteestasi. + OK + + Kirjoita + Lähetetäänkö viesti ilman aihetta? + Kyllä + Ei + + Lähetysvirhe + Sulje + Viestiäsi ei voida lähettää annettuun osoitteeseen seuraavasta syystä: + Luotettuja avaimia ei ole: %s + Osoitetta ei ole olemassa: %s + Salaa viesti + Salaa viesti Proton Mailin ulkopuolisille käyttäjille asettamalla sille salasana. + Lue lisää + Viestin salasana + Toista salasana + Salasanavihje (valinnainen) + 4–21 merkkiä + Salasanan on oltava 4–21 merkkiä + Salasanojen on täsmättävä + Salasanat eivät täsmää + Näytä salasana + Piilota salasana + Aseta salasana + Tallenna muutokset + Poista salasana + Vastaa rivihuomautuksella + + %d jäsen + %d jäsentä + + diff --git a/mail-composer/presentation/src/main/res/values-fr/strings.xml b/mail-composer/presentation/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..67e6ea4c2d --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-fr/strings.xml @@ -0,0 +1,106 @@ + + + + De : + À : + Cc : + Cci : + Objet + Écrire un message + Fermer l\'éditeur + Envoyer le message + Afficher les autres destinataires + L\'adresse e-mail n\'est pas valide. + L\'adresse de l\'expéditeur n\'est pas valide. + Modifier l\'expéditeur + Vous devez disposer d\'un abonnement payant à Proton Mail pour modifier l\'adresse de l\'expéditeur. + Impossible de changer l\'expéditeur : l\'abonnement de l\'utilisateur n\'a pu être obtenu. Veuillez réessayer. + L\'enregistrement de l\'adresse de l\'expéditeur n\'a pas abouti. + L\'enregistrement du corps du brouillon n\'a pas abouti. + Un ou plusieurs destinataires dupliqués ont été retirés. + L\'enregistrement de l\'objet n\'a pas abouti. + L\'enregistrement des destinataires du brouillon n\'a pas abouti. + Le contenu de ce brouillon n\'est pas disponible pour l\'instant. Sa modification remplacera tout contenu déjà existant. + Ajouter des pièces jointes + Importer depuis… + Les informations du message n\'ont pas pu être chargées. + Message d\'origine + Le %s, %s <%s> a écrit : + OK + Limite de pièces jointes atteinte + La taille limite pour les pièces jointes est de %1$s. + Chiffrer à nouveau la pièce jointe n\'a pas abouti. Pour des raisons de sécurité, toutes les pièces jointes ont été retirées. + La pièce jointe est introuvable. + Le message du brouillon d\'origine n\'a pas pu être chargé. + La pièce jointe n\'a pas pu être stockée. Nous vous invitons à réessayer. + La dernière version de ce brouillon n\'a pas pu être chargée. Toute modification remplacera son contenu. + Ajouter un mot de passe + Définir la date d\'expiration + Expiration du message + Définir + Aucun + 1 heure + 1 jour + 3 jours + 1 semaine + Personnaliser + La définition du délai d\'expiration n\'a pas abouti. + + Les messages avec délai d\'expiration ne sont pas pris en charge. + Nous vous recommandons de configurer un mot de passe pour les destinataires suivants : + Annuler + Envoyer quand même + + Envoi de notification + L\'envoi de messages depuis une adresse @pm.me est une fonctionnalité payante. Votre message sera envoyé à partir de votre adresse par défaut. + L\'expéditeur sélectionné est désactivé. Votre message sera envoyé à partir de votre adresse par défaut. + L\'adresse de l\'expéditeur d\'origine n\'a pas pu être obtenue. Votre message sera envoyé à partir de votre adresse par défaut. + OK + + Rédiger + Envoyer le message sans objet ? + Oui + Non + + Une erreur s\'est produite pendant l\'envoi. + Fermer + Votre message ne peut pas être envoyé à l\'adresse indiquée pour la raison suivante : + Il n\'y a pas de clés de confiance : %s + L\'adresse n\'existe pas : %s + Chiffrer le message + Définissez un mot de passe pour chiffrer ce message à destination de personnes qui n\'utilisent pas Proton Mail. + En savoir plus + Mot de passe du message + Confirmer le mot de passe + Indice de mot de passe (facultatif) + 4 à 21 caractères + Le mot de passe doit contenir entre 4 et 21 caractères. + Les mots de passe doivent être identiques. + Les mots de passe ne correspondent pas. + Afficher le mot de passe + Masquer le mot de passe + Appliquer le mot de passe + Enregistrer les modifications + Retirer le mot de passe + Répondre dans le message + + %d membre + %d membres + + diff --git a/mail-composer/presentation/src/main/res/values-hi/strings.xml b/mail-composer/presentation/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000000..7a69372bbf --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-hi/strings.xml @@ -0,0 +1,106 @@ + + + + से: + को: + सीसी: + बीसीसी: + विषय + ईमेल लिखें + रचनास्तान बंद करें + संदेश भेजें + अन्य प्राप्तकर्ता दिखाएँ + ईमेल पता अमान्य है + भेजने वाले का पता अमान्य है + भेजने वाले बदलें + भेजने वाले का पता बदलने के लिए आपको पेड Proton Mail सदस्यता की आवश्यकता है + भेजने वाले को बदलना असंभव है क्योंकि उपयोगकर्ता की सदस्यता प्राप्त करने में विफलता हुई। कृपया पुनः प्रयास करें। + भेजने वाले का पता सहेजा नहीं गया + ड्राफ्ट ईमेल की सामग्री सहेजी नहीं गई + डुप्लिकेट प्राप्तकर्ता(ओं) को हटा दिया गया + विषय पंक्ति सहेजी नहीं गई + इस ड्राफ्ट के प्राप्तकर्ताओं को सहेजना विफल रहा + इस ड्राफ्ट की सामग्री वर्तमान में उपलब्ध नहीं है। संपादन किसी भी पूर्व-मौजूद सामग्री को ओवरराइड कर देगा। + अटैचमेंट जोड़ें + लाया गया… + संदेश की जानकारी लोड नहीं की जा सकी + पुराने संदेश + %s को, %s <%s> ने लिखा था: + ठीक है + अटैचमेंट की सीमा पार हो गई + अनुलग्नकों की आकार सीमा %1$s है। + अटैचमेंट का पुन: एन्क्रिप्शन असफल हुआ। सुरक्षा कारणों से सभी अटैचमेंट हटाए गए। + अटैचमेंट नहीं मिला। + मूल ड्राफ्ट संदेश लोड नहीं हो सका। + अटैचमेंट को स्टोर करने में असमर्थ। कृपया पुनः प्रयास करें। + इस ड्राफ्ट का नवीनतम संस्करण लोड करने में विफल रहा। कोई भी परिवर्तन इसकी सामग्री को ओवरराइड कर देगा। + पासवर्ड जोड़ें + समाप्ति समय निर्धारित करें + संदेश एक्सपायरेशन + सेट करें + कुछ नहीं + 1 घंटा + 1 दिन + 3 दिन + 1 हफ्ता + अनुकूलित + समाप्ति समय निर्धारित करने में विफल रहा + + समाप्ति समर्थित नहीं + हम इसके बजाय निम्नलिखित प्राप्तकर्ताओं के लिए पासवर्ड सेट करने की अनुशंसा करते हैं: + रद्द करें + फिर भी भेजें + + नोटिस भेजना + \@pm.me से संदेश भेजना एक पेड खासियत है। आपका संदेश आपके डिफॉल्ट पते से भेजा जाएगा। + चुना हुआ भेजने वाले का पता अक्षम है। आपका संदेश आपके डिफॉल्ट पते से भेजा जाएगा। + मूल भेजने वाले का पता प्राप्त नहीं किया जा सका। आपका संदेश आपके डिफॉल्ट पते से भेजा जाएगा। + ठीक + + लिखें + बिना विषय के संदेश भेजें? + हाँ + नहीं + + भेजने में त्रुटि + बंद करें + दर्ज किए गए पते पर आपका ईमेल निम्नलिखित कारण के चलते नहीं भेजा जा सकता है: + कोई विश्वसनीय चाबी नहीं है: %s + पता मौजूद नहीं है: %s + संदेश एन्क्रिप्ट करें + गैर-Proton Mail उपयोगकर्ताओं के लिए इस संदेश को एन्क्रिप्ट करने के लिए एक पासवर्ड सेट करें | + और जानें + संदेश पासवर्ड + पासवर्ड दोहराएं + पासवर्ड संकेत (वैकल्पिक) + 4 से 21 अक्षर लंबा + पासवर्ड 4 और 21 वर्णों के बीच लंबा होना चाहिए + पासवर्ड मेल खाने चाहिए + पासवर्ड मेल नहीं खा रहे + पासवर्ड दिखाएँ + पासवर्ड छिपाएं + पासवर्ड लागू करें + बदलाव सहेजें + पासवर्ड हटाएं + इनलाइन जवाब दें + + %d सदस्य + %d सदस्य + + diff --git a/mail-composer/presentation/src/main/res/values-hr/strings.xml b/mail-composer/presentation/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..dc2b634063 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-hr/strings.xml @@ -0,0 +1,107 @@ + + + + Od: + Za: + Kopija: + Skrivena kopija: + Predmet + Nova poruka e-pošte + Zatvori prozor za novu poruku + Pošalji poruku + Show other recipients + Nevažeća adresa e-pošte + Sender address is invalid + Change sender + You need a paid Proton Mail subscription to change the sender address + Cannot change sender as getting user\'s subscription failed. Try again. + Sender address wasn\'t saved + Draft email body wasn\'t saved + Duplicated recipient(s) removed + Subject line wasn\'t saved + Storing this draft\'s recipients failed + The content of this draft is not available at this time. Editing will override any pre-existing content. + Dodaj privitke + Uvoz iz… + Could not load the message\'s information + Original Message + On %s, %s <%s> wrote: + U redu + Attachment limit reached + The size limit for attachments is %1$s + Reencryption of attachment failed. For security reasons all attachments got removed. + Attachment not found. + Unable to load the original draft message. + Unable to store the attachment. Please try again. + The latest version of this draft failed to load. Any change will override its content. + Add password + Postavi datum isteka + Istek poruke + Postavi + Ništa + 1 sat + 1 dan + 3 dana + 1 tjedan + Prilagođeno + Setting expiration time failed + + Vremenski istek nije podržan + Umjesto toga preporučujemo postavljanje lozinke za sljedeće primatelje: + Poništi + Svejedno pošalji + + Slanje obavijesti + Sending messages from @pm.me is a paid feature. Your message will be sent from your default address. + The selected sender address is disabled. Your message will be sent from your default address. + The original sender address could not be obtained. Your message will be sent from your default address. + U redu + + Nova poruka + Poslati poruku bez predmeta? + Da + Ne + + Sending error + Zatvori + Your email cannot be sent to the address entered due to the following reason: + There are no Trusted Keys: %s + Address does not exist: %s + Encrypt message + Postavite lozinku za šifriranje ove poruke za one koji nisu korisnici ProtonMaila. + Learn more + Lozinka poruke + Ponovite lozinku + Password hint (optional) + 4 do 21 znakova dugo + The password must be between 4 and 21 characters + Lozinke se moraju poklapati + Lozinke se ne poklapaju + Pokaži lozinku + Sakrij lozinku + Primjeni lozinku + Spremi promjene + Ukloni lozinku + Odgovori u ravnini s ostalim tekstom + + %d member + %d members + %d members + + diff --git a/mail-composer/presentation/src/main/res/values-hu/strings.xml b/mail-composer/presentation/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..1ccf70cc1b --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-hu/strings.xml @@ -0,0 +1,106 @@ + + + + Feladó: + Címzett: + Másolat: + Titkos másolat: + Tárgy + E-mail írása + Szerkesztő bezárása + Üzenet küldése + Más címzettek megjelenítése + Az e-mail cím érvénytelen + A küldő cím érvénytelen + Feladó módosítása + A feladó email címének megváltoztatásához Proton Mail előfizetésre van szükséged. + A feladó nem változtatható meg, mert az előfizetés betöltése nem sikerült. Próbálja újra. + A feladó címe nem lett elmentve + Az e-mail vázlat nem lett elmentve + Duplikált címzett(ek) eltávolítva + A tárgysor nem lett elmentve + A vázlat címzetteinek tárolása nem sikerült + Ennek a piszkozatnak a tartalma jelenleg nem elérhető. Szerkesztése felülírja az esetlegesen már korábban hozzáadott tartalmat. + Melléklet hozzáadása + Importálás innen… + Az üzenet adatainak betöltése nem sikerült + Eredeti üzenet + %s-kor, %s<%s> ezt írta: + OK + Elérte a mellékletekre vonatkozó korlátot + A mellékletek maximális mérete %1$s + A melléklet ismételt titkosítása nem sikerült. Biztonsági okokból minden melléklet eltávolításra került. + A csatolmány nem található. + Nem sikerült betölteni az eredeti üzenetvázlatot. + Nem sikerült tárolni a mellékletet. Kérjük, próbálja újra. + A vázlat legutóbbi változatának betöltése nem sikerült. Bármilyen változtatás felülírja a tartalmát. + Jelszó hozzáadása + Időkorlát megadása + Időkorlátos üzenet + Beállítás + Nincs + 1 óra + 1 nap + 3 nap + 1 hét + Egyéni + Az időkorlát beállítása nem sikerült + + Lejárat nem támogatott + Javasoljuk, hogy a következő címzetteknek inkább állítson be egy jelszót: + Mégsem + Küldés mindenképpen + + Küldési tudnivaló + Üzenetek küldése a @pm.me címről előfizetéshez kötött. Az üzenet az alapértelmezett címről kerül elküldésre. + A kiválasztott feladási cím ki van kapcsolva. Az üzenet az alapértelmezett címéről kerül elküldésre. + Az eredeti feladó címét nem sikerült kideríteni. Az üzenetet az Ön alapértelmezett címéről küldjük el. + OK + + Levélírás + Elküldi az üzenetet tárgy nélkül? + Igen + Nem + + Hiba a küldés közben + Bezárás + A megadott címre a következő ok miatt nem lehet elküldeni az e-mailt: + Nincs megbízható kulcs: %s + Cím nem létezik: %s + Üzenet titkosítása + Állítson be egy jelszót a nem Proton Mailes címzetteknek küldött üzenet titkosításához. + További információk + Üzenet jelszó + Jelszó ismét + Jelszó emlékeztető (nem kötelező) + 4-től 21 karakter hosszúságig + A jelszónak legalább 4, és legfeljebb 21 karakter hosszúnak kell lennie + A jelszavaknak egyezniük kell + A jelszavak nem egyeznek + Jelszó megjelenítése + Jelszó elrejtése + Jelszó alkalmazása + Mentés + Jelszó eltávolítása + Válaszolás az eredeti levélben + + %d tag + %d tag + + diff --git a/mail-composer/presentation/src/main/res/values-in/strings.xml b/mail-composer/presentation/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..a62ec25c5c --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-in/strings.xml @@ -0,0 +1,105 @@ + + + + Dari: + Kepada: + Cc: + Bcc: + Judul + Tulis email + Tutup komposer + Kirim pesan + Tampilkan penerima lainnya + Alamat email tidak valid + Alamat pengirim tidak valid + Ubah pengirim + Anda memerlukan langganan Proton Mail berbayar untuk mengubah alamat pengirim + Pengirim tidak dapat diubah karena status langganan pengguna gagal dimuat. Silakan coba lagi. + Alamat pengirim tidak disimpan + Isi email draf tidak disimpan + Penerima yang terduplikasi dihapus + Baris subjek tidak disimpan + Gagal menyimpan draf penerima + Konten draf ini sedang tidak tersedia. Dengan menyunting pesan, isi draf yang telah ada sebelumnya akan tertimpa. + Tambah lampiran + Impor dari… + Informasi pesan tidak dapat dimuat + Pesan Asli + Pada %s, %s <%s> menulis: + OK + Batas lampiran tercapai + Batas ukuran lampiran adalah %1$s + Lampiran gagal dienkripsi ulang. Demi alasan keamanan, seluruh lampiran telah dihapus. + Lampiran tidak ditemukan. + Tidak dapat memuat pesan draf asli. + Tidak dapat menyimpan lampiran. Silakan mencoba lagi. + Versi terbaru draf ini gagal dimuat. Perubahan apa pun akan menimpa draf ini. + Tambahkan kata sandi + Tentukan waktu kedaluwarsa + Waktu kedaluwarsa pesan + Atur + Tidak ada + 1 jam + 1 hari + 3 hari + 1 minggu + Sesuaikan + Pengaturan waktu kedaluwarsa gagal + + Pemasangan waktu kedaluwarsa tidak didukung + Kami merekomendasikan pengaturan kata sandi untuk penerima berikut ini: + Batal + Tetap kirim + + Mengirim pemberitahuan + Pengiriman pesan dari alamat @pm.me merupakan fitur berbayar. Pesan Anda akan dikirim dari alamat bawaan Anda. + Alamat pengirim yang dipilih dinonaktifkan. Pesan Anda akan dikirim dari alamat bawaan Anda. + Alamat pengirim asli tidak bisa didapatkan. Pesan Anda akan dikirim dari alamat bawaan Anda. + OK + + Tulis + Kirim pesan tanpa judul? + Ya + Tidak + + Pesan gagal dikirim + Tutup + Email Anda tidak dapat dikirim ke alamat email yang dimasukkan karena alasan berikut: + Tidak ada Kunci Terpercaya: %s + Alamat tidak ada: %s + Enkripsi pesan + Buat kata sandi untuk mengenkripsi pesan ini bagi pengguna non-Proton Mail. + Pelajari lebih lanjut + Kata sandi pesan + Ulangi kata sandi + Petunjuk kata sandi (opsional) + Panjangnya 4 hingga 21 karakter + Kata sandi harus terdiri dari 4 hingga 21 karakter + Kata sandi harus seragam + Kata sandi tidak sesuai + Tampilkan kata sandi + Sembunyikan kata sandi + Terapkan kata sandi + Simpan perubahan + Hapus kata sandi + Tanggapi secara in-line + + %d anggota + + diff --git a/mail-composer/presentation/src/main/res/values-it/strings.xml b/mail-composer/presentation/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..90efae3025 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-it/strings.xml @@ -0,0 +1,106 @@ + + + + Da: + A: + Cc: + Ccn: + Oggetto + Scrivi un messaggio + Chiudi il compositore + Invia il messaggio + Mostra gli altri destinatari + Indirizzo email non valido + Indirizzo mittente non valido + Cambia il mittente + Passa a un abbonamento Proton Mail a pagamento per cambiare l\'indirizzo mittente + Caricamento dell\'abbonamento non riuscito. Cambio del mittente non riuscito. + Salvataggio del mittente della bozza non riuscito + Salvataggio del contenuto della bozza non riuscito + Destinatari duplicati rimossi + Salvataggio dell\'oggetto della bozza non riuscito + Salvataggio dei destinatari della bozza non riuscito + Contenuto della bozza momentaneamente non disponibile. Modificarla sovrascriverà qualsiasi contenuto già esistente. + Aggiungi degli allegati + Importa da... + Caricamento delle informazioni del messaggio non riuscito + Messaggio originale + %s, %s <%s> ha scritto: + OK + Limite degli allegati raggiunto + Il limite di dimensione per gli allegati è %1$s + Nuova crittografia dell\'allegato non riuscita. Allegati rimossi per motivi di sicurezza. + Allegato non trovato + Caricamento della bozza originale non riuscito + Salvataggio dell\'allegato non riuscito + Caricamento dell\'ultima versione della bozza non riuscito. Qualsiasi modifica sovrascriverà il suo contenuto. + Aggiungi una password + Imposta la scadenza + Scadenza + Salva + Nessuna + 1 ora + 1 giorno + 3 giorni + 1 settimana + Personalizzata + Impostazione della scadenza non riuscita + + Scadenza non supportata + Imposta una password per inviare il messaggio con scadenza a destinatari esterni. Sei sicuro di voler inviare questo messaggio senza una scadenza per i seguenti destinatari? + Annulla + Invia comunque + + Invia dall\'indirizzo email predefinito + L\'invio dei messaggi dall\'indirizzo @pm.me è una funzionalità a pagamento. Il messaggio verrà inviato dall\'indirizzo email predefinito. + L\'indirizzo mittente selezionato non è attivo. Il messaggio verrà inviato dall\'indirizzo email predefinito. + Caricamento dell\'indirizzo mittente originale non riuscito. Il messaggio verrà inviato dall\'indirizzo email predefinito. + Invia + + Invia il messaggio + Sei sicuro di voler inviare il messaggio senza un oggetto? + Invia + Annulla + + Invio del messaggio non riuscito + Chiudi + Invio del messaggio all\'indirizzo inserito non riuscito a causa del seguente motivo: + Nessuna chiave attendibile: %s + Indirizzo non esistente: %s + Crittografia esterna + Imposta una password per crittografare questo messaggio per gli utenti che non usano Proton Mail. + Scopri di più + Password del messaggio + Conferma della password + Suggerimento per la password (opzionale) + Password compresa tra 4 e 21 caratteri + Inserisci una password compresa tra 4 e 21 caratteri + Password confermata + Conferma correttamente la password + Mostra la password + Nascondi la password + Salva + Salva + Rimuovi + Rispondi in linea + + %d membro + %d membri + + diff --git a/mail-composer/presentation/src/main/res/values-ja/strings.xml b/mail-composer/presentation/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..ac174331e6 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ja/strings.xml @@ -0,0 +1,105 @@ + + + + 差出人: + 宛先: + Cc: + Bcc: + 件名 + メールの作成 + 閉じる + メッセージを送信 + 他の受信者を表示する + メールアドレスが無効です + 送信者アドレスが無効 + 送信者を変更 + 送信アドレスを変更するにはProton Mailサブスクリプションの契約が必要です + ユーザーのサブスクリプションの取得に失敗したため、送信者を変更できませんでした。もう一度お試しください。 + 下書きの送信元アドレスの保存に失敗しました + 下書き本文の保存に失敗しました + 重複した受取人(たち)が削除されました + 件名の保存に失敗しました + 下書きの宛先の保存に失敗しました + この下書きの内容は現時点では利用できません。編集すると既存の内容が上書きされます。 + 添付ファイルの追加 + インポート... + メッセージの情報を読み込めませんでした + 元のメッセージ + %s、%s <%s> 書きました: + OK + 添付ファイルの制限に達しました。 + 添付ファイルのサイズ制限は%1$sです。 + 添付ファイルの再暗号化に失敗しました。セキュリティ上の理由により、すべての添付ファイルは削除されました。 + 添付ファイルが見つかりません。 + 元の下書きメッセージを読み込めませんでした。 + 添付ファイルを保存できませんでした。もう一度お試しください。 + この下書きの最新バージョンの読み込みに失敗しました。変更するとコンテンツが上書きされます。 + パスワードを追加 + 有効期限を設定 + メッセージの有効期限 + 設定する + なし + 1 時間 + 1 日 + 3 日 + 1 週間 + カスタム + 有効期限の設定に失敗しました + + 有効期限はサポートされていません + 以下の宛先へパスワードを設定して送信することをおすすめします: + キャンセル + このまま送信 + + 通知の送信 + \@pm.me からのメッセージ送信は有料の機能です。あなたのメッセージはデフォルトのアドレスから送信されます。 + 選択された差出人のアドレスは無効です。あなたのメッセージはデフォルのアドレスから送信されます。 + 元の送信者アドレスを取得できませんでした。あなたのメッセージはデフォルトのアドレスから送信されます。 + OK + + 新規作成 + 件名なしでメッセージを送信しますか? + はい + いいえ + + 送信エラー + 閉じる + 次の理由により入力されたメールアドレスにメールを送信することができません: + 信頼済みのキーがありません:%s + メールアドレスが存在しません:%s + メッセージを暗号化 + Proton Mail以外のユーザー宛にこのメッセージを暗号化するためのパスワードを設定します。 + より詳しく + メッセージのパスワード + パスワードを再度入力 + パスワードのヒント(任意) + 4〜21 文字 + パスワードは4文字以上21文字以下で入力してください + パスワードが一致する必要があります + パスワードが一致しません + パスワードを表示 + パスワードを非表示 + パスワードを適用 + 変更を保存 + パスワードを削除 + インラインで返信 + + %d 人のメンバー + + diff --git a/mail-composer/presentation/src/main/res/values-ka/strings.xml b/mail-composer/presentation/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..7e6413bad7 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ka/strings.xml @@ -0,0 +1,106 @@ + + + + ვისგან: + ვის: + ასლი: + ბრმა ასლი: + საგანი + წერილის შედგენა + რედაქტორის დახურვა + შეტყობინების გაგზავნა + სხვა მიმღებების ჩვენება + ელფოსტის მისამართი არასწორია + გამგზავნის მისამართი არასწორია + გამგზავნის შეცვლა + გამგზავნის მისამართის შესაცვლელად Proton Mail-ის ფასიანი გამოწერა გჭირდებათ + გამგზავნის შეცვლა შეუძლებელია, რადგან მომხმარებლის გამოწერის მიღება ჩავარდა. თავიდან სცადეთ. + ამ მონახაზის გამგზავნის მისამართის შენახვა შეუძლებელია + ამ მონახაზის შემცველობის შენახვა შეუძლებელია + გამეორებული მიმღებები წაიშალა + ამ მონახაზის სათაურის შენახვა შეუძლებელია + ამ მონახაზის მიმღებების შენახვა შეუძლებელია + ამ მონახაზის შემცველობა ამჟამად ხელმისაწვდომი არაა. მისი ჩასწორება უკვე არსებულ შემცველობას გადაფარავს. + მიმაგრებული ფაილის დამატება + შემოტანა… + შეტყობინების ინფორმაციის ჩატვირთვა შეუძლებელია + ორიგინალური შეტყობინება + %s, %s < %s> დაწერა: + დიახ + მიმაგრების ლიმიტი მიღწეულია + მიმაგრებული ფაილების მაქსიმალური ზომაა %1$s + მიმაგრებული ფაილის თავიდან დაშიფვრა ჩავარდა. უსაფრთხოების მიზნით, ყველა მიმაგრებული ფაილი წაიშალა. + მიმაგრებული ფაილი აღმოჩენილი არაა. + ორიგინალი მონახაზი შეტყობინების ჩატვირთვა შეუძლებელია. + მიმაგრებული ფაილის დამახსოვრება შეუძლებელია. თავიდან სცადეთ. + მონახაზის უკანასკნელი ვერსიის ჩატივრთვა ჩავარდა. ნებისმიერი ცვლილება გადაფარავს მის შემცველობას. + პაროლის დამატება + ვადის დაყენება + შეტყობინების ვადა + დაყენება + არცერთი + 1 სთ + 1 დღე + 3 დღე + 1 კვირა + ხელით + ვადის ამოწურვის დროის დაყენება ჩავარდა + + ვადა მხარდაჭერილი არაა + გირჩევთ პაროლის დაყენებას შემდეგი მომხმარებლებისათვის: + გაუქმება + მაინც გაგზავნა + + შეტყობინების გაგზავნა + \@pm.ge-ით შეტყობინებების გაგზავნა ფასიანი ფუნქციაა. თქვენი შეტყობინება გაიგზავნება თქვენი ნაგულისხმევი მისამართიდან. + არჩეული გამგზავნის მისამართი გათიშულია. თქვენი შეტყობინება თქვენი ნაგულისხმევი მისამართით გაიგზავნება. + ორიგინალური გამგზავნის მისამართის მიღება ვერ მოხერხდა. თქვენი შეტყობინება ნაგულისხმევი მისამართიდან გაიგზავნება. + დიახ + + შედგენა + გავაგზავნო შეტყობინება საგნის გარეშე? + დიახ + არა + + გაგზავნის შეცდომა + დახურვა + თქვენი ელფოსტა მითითებულ მისამართზე ვერ გაიგზავნება შემდეგი მიზეზის გამო: + სანდო გასაღებების გარეშე: %s + მისამართი არ არსებობს: %s + შეტყობინების დაშიფრვა + ამ შეტყობინების არა-Proton Mail-ის მომხმარებლებისთვის დასაშიფრად დააყენეთ პაროლი. + გაიგეთ მეტი + შეტყობინების პაროლი + გაიმეორეთ პაროლი + პაროლის მინიშნება (არასავალდებულო) + 4-დან 15-მდე სიმბოლო + პაროლი სიგრძე 4 და 21 სიმბოლოს შორის უნდა მერყეობდეს + პაროლები უნდა ემთხვეოდეს + პაროლები არ ემთხვევა + პაროლის ჩვენება + პაროლის დამალვა + პაროლის დაყენება + ცვლილებების შენახვა + პაროლის მოშლა + პასუხის იქვე მიწერა + + %d წევრი + %d წევრი + + diff --git a/mail-composer/presentation/src/main/res/values-kab/strings.xml b/mail-composer/presentation/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..e57df9fc9d --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-kab/strings.xml @@ -0,0 +1,106 @@ + + + + Seg: + I: + Anɣ. I: + Anɣ. Uff. I: + Asentel + Aru imayl + Mdel amsuddes + Azen izen + Sken iɣerwaḍen niḍen + Tansa imayl mačči d tameɣtut + Tansa n umazan mačči d tameɣtut + Beddel amazan + You need a paid Proton Mail subscription to change the sender address + Cannot change sender as getting user\'s subscription failed. Try again. + Sender address wasn\'t saved + Draft email body wasn\'t saved + Duplicated recipient(s) removed + Subject line wasn\'t saved + Storing this draft\'s recipients failed + The content of this draft is not available at this time. Editing will override any pre-existing content. + Rnu imeddayen + Kter si… + Could not load the message\'s information + Original Message + On %s, %s <%s> wrote: + IH + Attachment limit reached + The size limit for attachments is %1$s + Reencryption of attachment failed. For security reasons all attachments got removed. + Attachment not found. + Unable to load the original draft message. + Unable to store the attachment. Please try again. + The latest version of this draft failed to load. Any change will override its content. + Rnu awal uffir + Sbadu azemz n ufati + Afati n yizen + Wennez + Ulac + 1 n usrag + 1 n wass + 3 n wussan + 1 umalas + Yugen + Setting expiration time failed + + Tanzagt ur tettusefrak ara + Ad nsemter aseɣwer n wawal uffir i yiuɣerwaḍen-agi: + Sefsex + Azen akken ibɣu yili + + Tuzzna n tamawt + Sending messages from @pm.me is a paid feature. Your message will be sent from your default address. + The selected sender address is disabled. Your message will be sent from your default address. + The original sender address could not be obtained. Your message will be sent from your default address. + IH + + Aru + Azen izen s war asentel? + Ih + Uhu + + Tuccḍa di tuzzna + Mdel + Your email cannot be sent to the address entered due to the following reason: + There are no Trusted Keys: %s + Address does not exist: %s + Wgelhen izen + Sbadu awal uffis i uwgelhen n yizen-agi i yiseqdacen ur yellin ara di Proton Mail. + Learn more + Awal n uffir n yizen + Ales awal uffir + Password hint (optional) + 8 ar 21 n yisekkilen + The password must be between 4 and 21 characters + Awalen uffiren ilaq ad ilin kifkif + Awalen uffiren ur mṣadan ara + Show password + Ffer awal uffir + Snes awal uffir + Sekles ibeddilen + Kkes wawal uffir + Err degi imayl + + %d n uεeggal + %d n yiεeggalen + + diff --git a/mail-composer/presentation/src/main/res/values-ko/strings.xml b/mail-composer/presentation/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..bdd6ee9254 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ko/strings.xml @@ -0,0 +1,105 @@ + + + + 보낸사람: + 받는 사람: + 참조: + 숨은참조: + 제목 + 이메일 작성 + 작성기 닫기 + 메시지 보내기 + 다른 수신인 보기 + 올바르지 않은 이메일 주소입니다 + 보낸 이의 주소가 잘못되었습니다 + 보낸 사람 변경 + 이 전송자의 주소를 변경하려면 유료 Proton Mail 구독이 필요합니다. + 사용자의 구독을 실패했기 때문에 발신자를 변경할 수 없습니다. 다시 시도하십시오. + 이 초안의 발신자 주소를 저장하는 데 실패했습니다. + 이 초안의 본문을 저장하는 것을 실패했습니다. + 중복된 참가자 삭제됨 + 이 초안의 제목을 저장하는 것을 실패했습니다. + 이 초안의 수신자를 저장하는 것을 실패했습니다. + 이 초안의 내용은 현재 사용할 수 없습니다. 기존 내용의 편집을 사용할 수 없습니댜. + 첨부 파일 추가 + 다음에서 가져오기… + 메시지 정보를 불러올 수 없습니다. + 원본 메시지 + %s에 %s < %s> 님이 작성함: + 확인 + 첨부파일 한도 도달 + 첨부 파일의 최대 크기는 %1$s 입니다 + 첨부 파일에 대한 복호화 실패. 보안 상의 이유로 전체 첨부 파일이 삭제되었습니다. + 첨부 파일을 찾을 수 없음. + 원본 임시 저장 메시지를 불러올 수 없음. + 첨부 파일을 저장하지 못했습니다. 다시 시도하세요. + 이 원고의 가장 최근 버전을 불러오는 데 실패했습니다. 어떤 변경점이 있더라도 그것을 덮어쓰게 됩니다. + 비밀번호 추가 + 만료 시간 설정 + 메시지 유효기간 + 설정하기 + 없음 + 1시간 + 1일 + 3일 + 1주 + 커스텀 + 만료 시간 설정 실패 + + 유효기간을 지원하지 않습니다 + 다음 수신자를 위해 대신 비밀번호를 설정하는 것이 좋습니다: + 취소 + 이대로 보내기 + + 전송 알림 + \@pm.me 주소에서 메시지를 보내는 것은 유료 기능입니다. 사용자의 메시지는 기본주소에서 발송됩니다 + 선택한 발신자 주소가 비활성화되었습니다. 사용자의 메시지는 설정된 기본 주소에서 전송됩니다. + 원래 보낸 사람의 주소를 가져올 수 없습니다. 메시지가 사용자의 기본 주소에서 전송됩니다. + 확인 + + 작성 + 제목없이 메시지를 보낼까요? + + 아니오 + + 발송 실패 + 닫기 + 다음 이유로 인해 입력한 주소로 이메일이 전송되지 못했습니다: + 신뢰 키가 없습니다: %s + 주소가 존재하지 않습니다: %s + 메시지 암호화 + Proton Mail을 사용하지 않는 사용자를 위해 이 메시지를 암호화하기 위한 비밀번호를 설정하십시오. + 더 알아보기 + 메시지 비밀번호 + 비밀번호 확인 + 비밀번호 힌트 (선택 사항) + 4~21자로 작성해주세요 + 비밀번호는 4자에서 21자 사이여야 합니다. + 비밀번호가 일치해야 합니다 + 비밀번호가 일치하지 않습니다 + 비밀번호 표시 + 비밀번호 숨김 + 비밀번호 적용 + 변경 사항 저장 + 비밀번호 제거 + 본문 포함 회신 + + %d명 회원 + + diff --git a/mail-composer/presentation/src/main/res/values-nb-rNO/strings.xml b/mail-composer/presentation/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..6cb22376fd --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,106 @@ + + + + Fra: + Til: + Kopi: + Blindkopi: + Emne + Skriv e-post + Lukk skrivevindu + Send melding + Vis andre mottakere + E-postadressen er ugyldig + Avsenderadressen er ugyldig + Endre avsender + Du trenger et betalt Proton Mail-abonnement for å endre avsenderadressen + Kan ikke endre avsender da innhenting av brukerens abonnement mislyktes. Prøv igjen. + Avsenderadressen ble ikke lagret + Meldingsinnholdet til dette utkastet ble ikke lagret + Duplisert(e) mottaker(e) fjernet + Emnelinjen ble ikke lagret + Lagring av mottakerne til dette utkastet mislyktes + Innholdet i dette utkastet er ikke tilgjengelig på dette tidspunktet. Redigering vil overstyre eksisterende innhold. + Legg til vedlegg + Importer fra… + Kunne ikke laste inn meldingens informasjon + Opprinnelig melding + Den %s, %s <%s> skrev: + OK + Vedleggsgrensen er nådd + Størrelsesgrensen for vedlegg er %1$s + Kryptering av vedlegg på nytt mislyktes. Av sikkerhetsmessige årsaker ble alle vedlegg fjernet. + Vedlegg ikke funnet. + Kan ikke laste inn den opprinnelige utkastmeldingen. + Kan ikke lagre vedlegget. Prøv igjen. + Den nyeste versjonen av dette utkastet kunne ikke lastes inn. Enhver endring vil overstyre innholdet. + Legg til passord + Angi utløpstid + Utløpstid for melding + Angi + Ingen + 1 time + 1 dag + 3 dager + 1 uke + Tilpasset + Innstilling av utløpstid mislyktes + + Utløpstid er ikke støttet + Vi anbefaler at du i stedet konfigurerer et passord for følgende mottakere: + Avbryt + Send uansett + + Sendevarsel + Sending av meldinger fra @pm.me er en betalt funksjon. Meldingen vil bli sendt fra standardadressen din . + Den valgte avsenderadressen er deaktivert. Meldingen sendes fra standardadressen din. + Den opprinnelige avsenderadressen kunne ikke hentes. Meldingen vil bli sendt fra standardadressen din. + OK + + Skriv e-post + Sende melding uten emne? + Ja + Nei + + Feil ved sending + Lukk + E-posten din kan ikke sendes til den oppgitte e-postadressen av følgende årsak: + Det er ingen klarerte nøkler: %s + Adressen eksisterer ikke: %s + Krypter melding + Angi et passord for å kryptere denne meldingen for ikke-Proton Mail-brukere. + Lær mer + Meldingspassord + Gjenta passord + Passordhint (valgfritt) + 4 til 21 tegn lang + Passordet må bestå av mellom 4 og 21 tegn + Passordene må samsvare + Passordene samsvarer ikke + Vis passord + Skjul passord + Anvend passord + Lagre endringer + Fjern passord + Svar i tråd + + %d medlem + %d medlemmer + + diff --git a/mail-composer/presentation/src/main/res/values-nl/strings.xml b/mail-composer/presentation/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..af0628b14e --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-nl/strings.xml @@ -0,0 +1,106 @@ + + + + Van: + Aan: + CC: + BCC: + Onderwerp + E-mail opstellen + Opstelvenster sluiten + Verzend bericht + Andere ontvangers weergeven + E-mailadres is ongeldig + Adres verzender is ongeldig + Verander afzender + U heeft een betaald Proton Mail abonnement nodig om het afzenderadres te wijzigen + Kan de afzender niet wijzigen omdat het ophalen van het abonnement van de gebruiker is mislukt. Probeer het nog eens. + Adres van de afzender werd niet bewaard + Concept e-mail bericht is niet opgeslagen + Dubbele ontvanger(s) verwijderd + Onderwerp lijn is niet opgeslagen + Opslaan van de ontvangers van dit concept is mislukt + De inhoud van deze conceptversie is op dit moment niet beschikbaar. Bewerking zal eventuele al bestaande inhoud overschrijven. + Bestanden toevoegen + Importeren van… + Kon de informatie van het bericht niet laden + Oorspronkelijk bericht + Op %s, schreef %s <%s>: + OK + Bijlage-limiet bereikt + De maximale grootte voor bijlagen is 1%1$s + Herversleuteling van bijlage mislukt. Wegens veiligheidsredenen werden alle bijlagen verwijderd. + Bijlage niet gevonden. + Kan het originele conceptbericht niet laden. + Kan de bijlage niet opslaan. Probeer het opnieuw. + Het laden van de laatste versie van dit concept is mislukt. Wijzigingen zal het inhoud overschrijven. + Wachtwoord toevoegen + Stel vervaltijd in + Vervaldatum bericht + Instellen + Geen + 1 uur + 1 dag + 3 dagen + 1 week + Aangepast + Vervaltijd instellen mislukt + + Vervaldatum niet ondersteund + Wij raden aan een wachtwoord in te stellen voor de volgende ontvangers: + Annuleren + Toch verzenden + + Melding versturen + Berichten versturen vanaf een @pm.me-adres is enkel beschikbaar voor betalende gebruikers. Uw bericht wordt verzonden vanaf. + Het geselecteerde afzenderadres is uitgeschakeld. Uw bericht wordt vanaf uw standaard adres verzonden. + Het geselecteerde afzenderadres kon niet opgehaald worden. Uw bericht wordt vanaf uw standaard adres verzonden. + OK + + Opstellen + Bericht versturen zonder onderwerp? + Ja + Nee + + Fout bij verzenden + Sluiten + Uw e-mail kan niet worden verzonden naar het e-mailadres om de volgende reden: + Er zijn geen vertrouwde sleutels: %s + Adres bestaat niet: %s + Bericht versleutelen + Voer een wachtwoord in om dit bericht te versleutelen voor niet-Proton Mailgebruikers. + Meer informatie + Wachtwoord van bericht + Wachtwoord herhalen + Wachtwoordhint (Optioneel) + 4 tot 21 tekens lang + Het wachtwoord moet tussen de 4 en 21 tekens lang zijn + Wachtwoorden moeten overeenkomen + De wachtwoorden komen niet overeen. + Toon wachtwoord + Wachtwoord verbergen + Wachtwoord toepassen + Wijzigingen opslaan + Verwijder wachtwoord + Inline reageren + + %d lid + %d leden + + diff --git a/mail-composer/presentation/src/main/res/values-pl/strings.xml b/mail-composer/presentation/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..6bebea0f83 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-pl/strings.xml @@ -0,0 +1,108 @@ + + + + Od: + Do: + Dw: + Udw: + Temat + Napisz wiadomość + Zamknij edytor + Wyślij wiadomość + Pokaż innych odbiorców + Adres e-mail jest nieprawidłowy + Adres nadawcy jest nieprawidłowy + Zmień nadawcę + Do zmiany adresu nadawcy wymagana jest płatna subskrypcja Proton Mail + Nie można zmienić nadawcy, ponieważ pobranie informacji o subskrypcji użytkownika nie powiodło się. Spróbuj ponownie. + Adres nadawcy nie został zapisany + Treść szkicu nie została zapisana + Zduplikowani odbiorcy zostali usunięci + Temat nie został zapisany + Zapisanie odbiorców szkicu nie powiodło się + Zawartość szkicu nie jest dostępna. Edycja zastąpi obecną zawartość. + Dodaj załączniki + Importuj z… + Nie można załadować danych wiadomości + Oryginalna wiadomość + %s, %s <%s> napisał(a): + OK + Limit załączników został osiągnięty + Maksymalny rozmiar załączników wynosi %1$s + Ponowne szyfrowanie załącznika nie powiodło się. Ze względów bezpieczeństwa wszystkie załączniki zostały usunięte. + Nie znaleziono załącznika. + Ładowanie oryginalnego szkicu wiadomości się nie powiodło. + Nie udało się zapisać załącznika. Spróbuj ponownie. + Nie udało się załadować najnowszej wersji szkicu. Wszelkie jej zmiany spowodują nadpisanie zawartości. + Dodaj hasło + Ustaw czas wygaśnięcia + Wygaśniecie wiadomości + Ustaw + Brak + 1 godzina + 1 dzień + 3 dni + 1 tydzień + Niestandardowy + Ustawienie czasu wygaśnięcia nie powiodło się + + Wygaśnięcie nie jest obsługiwane + Zalecamy ustawienie hasła dla następujących odbiorców: + Anuluj + Wyślij mimo to + + Wysyłanie powiadomienia + Wysyłanie wiadomości z adresu @pm.me jest płatną funkcją. Wiadomości zostaną wysłane z Twojego domyślnego adresu. + Adres nadawcy jest wyłączony. Wiadomości zostaną wysłane z Twojego domyślnego adresu. + Nie udało się uzyskać oryginalnego adresu nadawcy. Wiadomość zostanie wysłana z domyślnego adresu. + OK + + Napisz + Wysłać wiadomość bez tematu? + Tak + Nie + + Błąd wysyłania + Zamknij + Wiadomość e-mail nie może zostać wysłana na podany adres z następującego powodu: + Brak zaufanych kluczy: %s + Adres nie istnieje: %s + Zaszyfruj wiadomość + Ustaw hasło, aby zaszyfrować tę wiadomość dla użytkowników spoza Proton Mail. + Dowiedz się więcej + Hasło wiadomości + Potwierdź hasło + Podpowiedź do hasła (opcjonalnie) + od 4 do 21 znaków + Hasło musi zawierać od 4 do 21 znaków + Hasła muszą pasować do siebie + Hasła nie pasują do siebie + Pokaż hasło + Ukryj hasło + Zastosuj hasło + Zapisz zmiany + Usuń hasło + Odpowiedz w linii + + %d członek + %d użytkowników + %d użytkowników + %d użytkowników + + diff --git a/mail-composer/presentation/src/main/res/values-pt-rBR/strings.xml b/mail-composer/presentation/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..454eb06f79 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,106 @@ + + + + De: + Para: + Cc: + Cco: + Assunto + Escrever e-mail + Fechar compositor + Enviar mensagem + Mostrar outros destinatários + O endereço de e-mail não é válido. + O endereço do remetente não é válido. + Alterar o remetente + Você precisa de uma assinatura paga do Proton Mail para alterar o endereço do remetente + Não é possível alterar o remetente, pois a assinatura do usuário não pôde ser obtida. Tente novamente. + O endereço do remetente não foi salvo + O corpo do e-mail de rascunho não foi salvo + As duplicações do destinatário foram removidas + O assunto não foi salvo + Erro ao salvar os destinatários deste rascunho + O conteúdo deste rascunho não está disponível no momento. A edição substituirá qualquer conteúdo pré-existente. + Adicionar anexos + Importar de… + Não foi possível carregar a informação da mensagem + Mensagem original + Em %s, %s <%s> escreveu: + OK + Limite de anexos alcançado + O limite de tamanho para anexos é de %1$s + A recriptografia do anexo falhou. Por motivos de segurança, todos os anexos foram removidos. + Anexo não encontrado. + Não foi possível carregar a mensagem original do rascunho. + Não foi possível armazenar o anexo. Tente novamente. + A versão mais recente deste rascunho não foi carregada. Qualquer alteração substituirá seu conteúdo. + Adicionar senha + Definir a data de expiração + Expiração de mensagem + Definir + Nenhuma + 1 hora + 1 dia + 3 dias + 1 semana + Personalizar + Falha ao configurar o tempo de expiração + + Expiração não suportada + Recomendamos configurar uma senha para os seguintes destinatários: + Cancelar + Enviar mesmo assim + + Aviso de envio + O envio de mensagens do @pm.me é um recurso pago. A sua mensagem será enviada do seu endereço padrão. + O endereço de remetente selecionado está desativado. Sua mensagem será enviada do seu endereço padrão. + O endereço do remetente original não pôde ser obtido. Sua mensagem será enviada do seu endereço padrão. + OK + + Escrever + Enviar mensagem sem assunto? + Sim + Não + + Erro ao enviar + Fechar + Seu e-mail não pode ser enviado para o endereço inserido pelo seguinte motivo: + Não há chaves de confiança: %s + O endereço não existe: %s + Criptografar mensagem + Defina uma senha para criptografar esta mensagem para destinatários que não usam Proton Mail. + Saiba mais + Senha da mensagem + Repetir senha + Dica de senha (opcional) + de 4 a 21 caracteres + A sua senha deve ter entre 4 e 21 caracteres + As senhas devem ser iguais. + Senhas não correspondem + Mostrar senha + Ocultar senha + Aplicar senha + Salvar alterações + Remover senha + Responder no corpo + + %d membro + %d membros + + diff --git a/mail-composer/presentation/src/main/res/values-pt-rPT/strings.xml b/mail-composer/presentation/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..e0fc6c5e2d --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,106 @@ + + + + De: + Para: + Cc: + Bcc: + Assunto + Compor e-mail + Fechar compositor + Enviar mensagem + Mostrar os outros destinatários + O endereço de email não é válido. + O endereço do remetente é inválido + Alterar o remetente + É necessária uma subscrição paga do Proton Mail para alterar o endereço do remetente. + Não é possível alterar o remetente porque a subscrição do utilizador não pôde ser obtida. Tente novamente. + Erro ao guardar o endereço do remetente + O corpo do rascunho do e-mail não foi guardado. + Um ou mais destinatários duplicados foram removidos. + Erro ao guardar a linha de assunto + Erro ao guardar os destinatários deste rascunho + O conteúdo deste rascunho não está disponível no momento. A edição anulará qualquer conteúdo pré-existente. + Adicionar anexos + Importar de… + Não foi possível carregar as informações da mensagem. + Mensagem original + Em %s, %s < %s> escreveu: + OK + O limite de anexos foi atingido. + O limite de tamanho para anexos é de %1$s. + Erro na reencriptação do anexo. Por questões de segurança, todos os anexos foram removidos. + O anexo não foi encontrado. + Não foi possível carregar a mensagem original do rascunho. + Não foi possível armazenar o anexo. Tente novamente. + A versão mais recente deste rascunho não conseguiu carregar. Qualquer alteração substituirá o seu conteúdo. + Adicionar uma palavra-passe + Definir tempo de expiração + Expiração da mensagem + Definir + Nenhum + 1 hora + 1dia + 3 dias + 1 semana + Personalizar + Erro ao configurar tempo de expiração + + Expiração não suportada + Recomendamos antes definir uma palavra-passe para os seguintes destinatários: + Cancelar + Enviar mesmo assim + + Aviso de envio + Enviar mensagens do endereço @pm.me é uma funcionalidade paga. A sua mensagem será enviada do seu endereço padrão. + O endereço do remetente selecionado está desativado. A sua mensagem será enviada do seu endereço predefinido. + Não foi possível obter o endereço original do remetente. A sua mensagem será enviada a partir do seu endereço predefinido. + Aceitar + + Enviar + Enviar mensagem sem assunto? + Sim + Não + + Erro ao enviar + Fechar + O seu e-mail não pode ser enviado para o endereço indicado pelo seguinte motivo: + Não há chaves de confiança: %s + O endereço não existe: %s + Encriptar a mensagem + Defina uma palavra-passe para encriptar a mensagem para destinatários que não são de Proton Mail. + Saiba mais + Palavra-passe da mensagem + Repita a palavra-passe + Dica da palavra-passe (opcional) + 4 a 21 carateres + A palavra-passe deve ter entre 4 e 21 caracteres. + As palavras-passe têm de coincidir. + As palavras-passe não correspondem. + Mostrar a palavra-passe + Ocultar a palavra-passe + Aplicar a palavra-passe + Guardar as alterações + Remover a palavra-passe + Responder em linha + + %d membro + %d membros + + diff --git a/mail-composer/presentation/src/main/res/values-ro/strings.xml b/mail-composer/presentation/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000000..9becc70070 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ro/strings.xml @@ -0,0 +1,107 @@ + + + + De la: + Către: + C: + Ca: + Subiect + Compunere e-mail + Închidere panou editare + Trimitere mesaj + Afișare alți destinatari + Adresa de e-mail nu este validă. + Adresa expeditorului nu este validă. + Modificare expeditor + Aveți nevoie de un abonament Proton Mail plătit pentru a schimba adresa expeditorului. + Nu se poate schimba expeditorul deoarece obținerea datelor abonamentului utilizatorului a eșuat. Mai încercați. + Adresa expeditorului nu a fost salvată. + Conținutului acestei ciorne nu a fost salvat. + Destinatari duplicați eliminați + Subiectul nu a fost salvat. + Stocarea destinatarului acestei ciorne a eșuat. + Conținutul acestei ciorne nu este disponibil momentan. Editarea va înlocui orice conținut preexistent. + Adăugare atașamente + Importare din… + Nu s-au putut încărca informațiile mesajului. + Mesaj original + %s, %s <%s> a scris: + OK + S-a atins limita pentru atașamente + Limita dimensiunii atașamentelor este de %1$s + Recriptarea atașamentului a eșuat. Din motive de securitate, toate atașamentele au fost eliminate. + Atașament negăsit. + Nu se poate încărca mesajul ciornei originale. + Nu se poate stoca atașamentul. Reîncercați. + Nu s-a încărcat ultima versiune a acestei ciorne. Orice modificare va anula conținutul acesteia. + Adăugare parolă + Setare timp expirare + Expirare mesaj + Setare + Fără + o oră + o zi + 3 zile + o săptămână + Personal + Setarea timpului de expirare a eșuat. + + Expirarea nu e suportată + Se recomandă configurarea unei parole pentru următorii destinatari: + Anulare + Trimitere așa + + Notificare trimitere + Trimiterea de mesaje de la adrese cu domeniul @pm.me este o funcție cu plată. Mesajul vă va fi trimis de pe adresa implicită. + Adresa expeditorului selectată este dezactivată. Mesajul vă va fi trimis de la adresa implicită. + Adresa inițială a expeditorului nu a putut fi obținută. Mesajul va fi trimis de la adresa dvs. implicită. + OK + + Mesaj nou + Trimiteți mesajul fără subiect? + Da + Nu + + Eroare trimitere + Închidere + Mesajul nu poate fi trimis la adresa introdusă din următorul motiv: + Nu există chei de încredere: %s + Adresa nu există: %s + Criptare mesaj + Setați o parolă de criptare a acestui mesaj pentru cei ce nu folosesc Proton Mail. + Aflați mai multe + Parolă mesaj + Repetare parolă + Indiciu parolă (opțional) + Între 4 și 21 de caractere + Parola trebuie să aibă între 4 și 21 de caractere. + Parolele trebuie să corespundă. + Parolele nu coincid. + Afișare parolă + Ascundere parolă + Aplicare parolă + Salvare modificări + Eliminare parolă + Răspuns în mesaj + + %d membru + %d membri + %d de membri + + diff --git a/mail-composer/presentation/src/main/res/values-ru/strings.xml b/mail-composer/presentation/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..fbecf57e3d --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-ru/strings.xml @@ -0,0 +1,108 @@ + + + + От: + Кому: + Копия: + Скрытая копия: + Тема + Написать письмо + Закрыть редактор писем + Отправить сообщение + Показать других получателей + Недействительный адрес эл. почты + Адрес отправителя недействителен + Изменить отправителя + Вам нужна платная подписка Proton Mail, чтобы менять адрес отправителя + Не удаётся изменить отправителя, так как не удалось получить подписку пользователя. Попробуйте ещё раз. + Адрес отправителя не сохранён + Черновик письма не сохранён + Дублирующиеся получатели удалены + Не удалось сохранить тему этого черновика + Получатели черновика не сохранены + Содержимое этого черновика в данный момент недоступно. Изменение приведет к замене всего ранее существовавшего содержимого. + Добавить вложения + Импортировать из… + Не удалось загрузить информацию сообщения + Оригинальное Сообщение + %s, %s <%s> пишет: + Да + Достигнут лимит вложений + Максимальный размер вложений %1$s + Повторное шифрование вложений не удалось. В целях безопасности все вложения были удалены. + Вложение не найдено. + Не удалось загрузить исходное сообщение-черновик. + Не удалось сохранить вложение. Попробуйте еще раз. + Последняя версия этого черновика не загрузилась. Любые изменения заменят его содержимое. + Добавить пароль + Установить срок действия + Срок действия сообщения + Установить + Ничего + 1 час + 1 день + 3 дня + 1 неделя + Пользовательский + Не удалось установить время истечения срока действия + + Истечение срока действия не поддерживается + Вместо этого мы рекомендуем установить пароль для следующих получателей: + Отменить + Все равно отправить + + Примечание об отправке + Отправка сообщений с @pm.me является платной функцией. Ваше сообщение будет отправлено с вашего адреса по умолчанию. + Выбранный адрес отправителя отключён. Ваше сообщение будет отправлено с адреса по умолчанию. + Не удалось получить исходный адрес отправителя. Ваше сообщение будет отправлено с адреса по умолчанию. + Да + + Новое письмо + Отправить сообщение без темы? + Да + Нет + + Ошибка отправки + Закрыть + Ваше письмо не может быть отправлено на указанный адрес электронной почты по следующей причине: + Нет доверенных ключей: %s + Адрес не существует: %s + Зашифровать сообщение + Установите пароль для шифрования этого сообщения для пользователей, не использующих Proton Mail. + Подробнее + Пароль сообщения + Повторите пароль + Подсказка к паролю (необязательно) + от 4 до 21 символа + Пароль должен быть от 4 до 21 символа + Пароли должны совпадать + Пароли не совпадают + Показать пароль + Скрыть пароль + Применить пароль + Сохранить изменения + Удалить пароль + Ответить с цитированием + + %d участник + %d участника + %d участников + %d участника + + diff --git a/mail-composer/presentation/src/main/res/values-sk/strings.xml b/mail-composer/presentation/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..4ed35e951e --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-sk/strings.xml @@ -0,0 +1,108 @@ + + + + Od: + Komu: + Kópia (Cc): + Skrytá kópia (Bcc): + Predmet + Napísať e-mail + Zatvoriť editor + Odoslať správu + Zobraziť ostatných príjemcov + E-mailová adresa je neplatná + Adresa odosielateľa je neplatná + Zmeniť odosielateľa + Na zmenu odosielateľa potrebujete platené predplatné Proton Mail + Nepodarilo sa zmeniť adresu odosielateľa, pretože získanie predplatného zlyhalo. Skúste znova. + Adresa odosielateľa nebola uložená + Koncept emailu nebol uložený + Duplicitní príjemcovia odstránení + Riadok predmetu nebol uložený + Uloženie príjemcov tohto konceptu zlyhalo + Obsah tohto konceptu nie je momentálne dostupný. Úpravou sa prepíše akýkoľvek už existujúci obsah. + Pripojiť prílohy + Importovať z… + Nebolo možné načítať informácie o správe + Pôvodná správa + %s, %s <%s> napísal/a: + OK + Bol dosiahnutý limit príloh + Limit veľkosti príloh je %1$s + Opätovné zašifrovanie prílohy zlyhalo. Z bezpečnostných dôvodov boli všetky prílohy odstránené. + Príloha nenájdená. + Nie je možné načítať pôvodný koncept správy. + Nie je možné uložiť prílohu. Skúste to znova, prosím. + Najnovšiu verziu tohto konceptu sa nepodarilo načítať. Akákoľvek zmena prepíše jej obsah. + Pridať heslo + Nastaviť čas pre vypršanie platnosti + Platnosť správy + Nastaviť + Neobmedzená + 1 hodina + 1 deň + 3 dni + 1 týždeň + Vlastná + Nastavenie doby platnosti zlyhalo + + Expirácia nie je podporovaná + Odporúčame miesto toho nastaviť heslo pre nasledujúcich príjemcov: + Zrušiť + Napriek tomu odoslať + + Odoslanie oznámenia + Posielanie správ z domény @pm.me je platená funkcia. Vaša správa bude odoslaná z vašej predvolenej adresy. + Zvolená adresa odosielateľa je zakázaná. Vaša správa bude odoslaná z vašej predvolenej adresy. + Adresu pôvodného odosielateľa nebolo možné získať. Vaša správa bude odoslaná z vašej predvolenej adresy. + OK + + Napíšte + Poslať správu bez predmetu? + Áno + Nie + + Chyba pri odosielaní + Zavrieť + Váš email nemôže byť odoslaný na zadanú adresu z nasledujúceho dôvodu: + Žiadne dôveryhodné kľúče: %s + Adresa neexistuje: %s + Šifrovať správu + Nastavte heslo na zašifrovanie tejto správy pre používateľov, ktorí nemajú Proton Mail. + Zistiť viac + Heslo správy + Zopakovať heslo + Nápoveda k heslu (voliteľné) + 4 až 21 znakov + Heslo musí mať dĺžku 4 až 21 znakov + Heslá sa musia zhodovať + Heslá sa nezhodujú + Zobraziť heslo + Skryť heslo + Použiť heslo + Uložiť zmeny + Odstrániť heslo + Odpovedať v riadku + + %d člen + %d členovia + %d členov + %d členov + + diff --git a/mail-composer/presentation/src/main/res/values-sl/strings.xml b/mail-composer/presentation/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..aa8d1b490b --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-sl/strings.xml @@ -0,0 +1,108 @@ + + + + Od: + Za: + Kp: + Skp: + Zadeva + Sestavi sporočilo + Zapri urejevalnik + Pošlji sporočilo + Prikaži ostale prejemnike + E-poštni naslov ni veljaven + Pošiljateljev naslov ni veljaven + Spremeni pošiljatelja + Za spreminjanje naslova pošiljatelja potrebujete plačljivo naročnino na Proton Mail + Pošiljatelja ni mogoče spremeniti, ker pridobivanje uporabnikove naročnine ni uspelo. Poskusite znova. + Pošiljateljev naslov ni bil shranjen + Jedro sporočila ni bilo shranjeno + Podvojeni prejemnik(i) odstranjen(i) + Zadeva sporočila ni bila shranjena + Shranjevanje prejemnikov tega osnutka ni uspelo + Vsebina tega osnutka trenutno ni na voljo. Z urejanjem boste prepisali vso morebitno dosedanjo vsebino. + Dodaj priponke + Uvozi iz ... + Podatkov sporočila ni bilo mogoče naložiti + Izvorno sporočilo + Dne %s je %s <%s> napisal/-a: + V redu + Dosežena je omejitev velikosti priponk + Velikost priponk je omejena na %1$s + Vnovično šifriranje priponk ni bilo uspešno. Iz varnostnih razlogov so bile vse priponke odstranjene. + Priponke ni bilo mogoče najti. + Izvirnega osnutka sporočila ni mogoče naložiti. + Priponke ni bilo mogoče shraniti. Poskusite znova. + Zadnje različice tega osnutka ni bilo mogoče naložiti. Vsaka sprememba bo vplivala na njeno vsebino. + Dodaj geslo + Nastavi čas poteka + Potek veljavnosti sporočila + Nastavi + Brez + 1 ura + 1 dan + 3 dni + 1 teden + Po meri + Nastavitev časa poteka ni uspela + + Potek ni podprt + Priporočamo, da namesto tega nastavite geslo za naslednje prejemnike: + Prekliči + Vseeno pošlji + + Obvestilo pred pošiljanjem + Pošiljanje sporočil z naslova @pm.me je plačljiva funkcija. Sporočilo bo poslano z vašega privzetega naslova. + Izbrani naslov pošiljatelja je onemogočen. Sporočilo bo poslano z vašega privzetega naslova. + Izvirnega naslova pošiljatelja ni bilo mogoče pridobiti. Vaše sporočilo bo poslano z vašega privzetega naslova. + V redu + + Sestavi + Pošljem sporočilo brez zadeve? + Da + Ne + + Napaka pri pošiljanju + Zapri + Vaše e-pošte ni mogoče poslati na vneseni naslov zaradi naslednjega razloga: + Ni zaupanja vrednih ključev: %s + Naslov ne obstaja: %s + Šifriraj sporočilo + Nastavi geslo za šifriranje tega sporočila za tiste, ki niso uporabniki Proton Maila. + Več informacij + Geslo sporočila + Ponovite geslo + Namig za geslo (izbirno) + 4 do 21 znakov + Geslo mora biti dolgo najmanj 4 in največ 21 znakov + Gesli se morata ujemati + Gesli se ne ujemata. + Prikaži geslo + Skrij geslo + Nastavi geslo + Shrani spremembe + Odstrani geslo + Odgovori v vrstici + + %d član + %d člana + %d člani + %d članov + + diff --git a/mail-composer/presentation/src/main/res/values-sv-rSE/strings.xml b/mail-composer/presentation/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..39e787d919 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,106 @@ + + + + Från: + Till: + Kopia: + Hemlig kopia: + Ämne + Skriv e-post + Stäng redigeringsfönstret + Skicka meddelande + Visa andra mottagare + E-postadressen är ogiltig + Avsändaradress är ogiltig + Ändra avsändare + Du behöver en betald Proton Mail-prenumeration för att ändra avsändaradressen + Kan inte ändra avsändare eftersom hämtning av användarens prenumeration misslyckades. Försök igen. + Avsändaradress sparades inte + Utkast till e-posttext sparades inte + Duplicerad mottagare borttagen + Ämnesrad sparades inte + Det gick inte att lagra utkastets mottagare + Innehållet i detta utkast är inte tillgängligt just nu. Redigering kommer att åsidosätta allt befintligt innehåll. + Lägg till bilagor + Importera från… + Kunde inte ladda meddelandets information + Ursprungligt meddelande + Den %s, %s <%s> skrev: + OK + Övre gräns för bilagor har uppnåtts + Storleksgränsen för bilagor är %1$s + Omkryptering av bilaga misslyckades. Av säkerhetsskäl har alla bilagor tagits bort. + Bilaga hittades inte. + Det går inte att läsa in det ursprungliga meddelandeutkastet. + Kan inte lagra bilagan. Försök igen. + Den senaste versionen av detta utkast misslyckades att laddas. Alla ändringar kommer att åsidosätta dess innehåll. + Lägg till lösenord + Ställ in utgångstid + När meddelandet löper ut + Ställ in + Ingen + 1 timme + 1 dag + 3 dagar + 1 vecka + Anpassad + Misslyckades ställa in utgångstid + + Förfallotid stöds inte + Vi rekommenderar att du ställer in ett lösenord istället för följande mottagare: + Avbryt + Skicka ändå + + Skickar avisering + Att skicka meddelanden från @pm.me är en betald funktion. Ditt meddelande kommer att skickas från din standardadress. + Den valda avsändaradressen är inaktiverad. Ditt meddelande kommer att skickas från din standardadress. + Den ursprungliga avsändaradressen kunde inte erhållas. Ditt meddelande kommer att skickas från din standardadress. + OK + + Skriv + Skicka meddelande utan ämne? + Ja + Nej + + Sändningsfel + Stäng + Din e-post kan inte skickas till den angivna adressen av följande anledning: + Det finns inga betrodda nycklar: %s + Adress finns inte: %s + Kryptera meddelande + Ställ in ett lösenord för att kryptera detta meddelande för icke-Proton Mail-användare. + Lär dig mer + Meddelandelösenord + Upprepa lösenord + Lösenordsledtråd (valfritt) + 4 till 21 tecken långt + Lösenordet måste vara mellan 4 och 21 tecken + Lösenorden måste matcha + Lösenorden matchar inte + Visa lösenord + Dölj lösenord + Tillämpa lösenord + Spara ändringar + Ta bort lösenord + Svara i tråd + + %d medlem + %d medlemmar + + diff --git a/mail-composer/presentation/src/main/res/values-tr/strings.xml b/mail-composer/presentation/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..209317a41e --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-tr/strings.xml @@ -0,0 +1,106 @@ + + + + Kimden: + Kime: + Kopya: + Gizli kopya: + Konu + E-posta oluştur + Oluşturucuyu kapat + İleti gönder + Diğer alıcıları görüntüle + E-posta adresi geçersiz + Gönderici adresi geçersiz + Göndericiyi değiştir + Gönderici adresini değiştirebilmek için ücretli bir Proton Mail aboneliğiniz olmalıdır + Kuıllanıcının abonelik bilgileri alınamadığından gönderici değiştirilemedi. Yeniden deneyin. + Gönderici adresi kaydedilemedi + Taslak e-postanın içeriği kaydedilemedi + Çift alıcılar kaldırıldı + Konu satırı kaydedilemedi + Bu taslağın alıcıları kaydedilemedi + Bu taslağın içeriği şu anda kullanılamıyor. Düzenleme daha önce var olan içerikleri değiştirecek. + Dosyalar ekle + Şuradan içe aktar… + İleti bilgileri yüklenemedi + Özgün ileti + %s zamanında, %s <%s> şunu yazdı: + Tamam + Ek dosya boyutu sınırına ulaşıldı + Ek dosyaların boyutu %1$s ile sınırlıdır + Ek dosya yeniden şifrelenemedi. Güvenlik nedeniyle tüm ek dosyalar kaldırıldı. + Ek dosya bulunamadı. + Özgün taslak ileti yüklenemedi. + Ek dosya kaydedilemedi. Lütfen yeniden deneyin. + Taslağın son sürümü yüklenemedi. Yapılmış değişiklikler geçersiz olacak. + Parola ekle + Geçerlilik süresi ayarla + İleti geçerlilik süresi + Ayarla + Yok + 1 saat + 1 gün + 3 gün + 1 hafta + Özel + Geçerlilik süresi ayarlanamadı + + Geçerlilik süresi desteklenmiyor + Bunun yerine, şu alıcılar için bir parola ayarlanmasını öneriyoruz: + İptal + Yine de gönder + + Bildirim gönderimi + \@pm.me adresinden ileti göndermek ücretli bir özelliktir. İletiniz varsayılan adresinizden gönderilecek + Seçilmiş gönderici adresi kullanımdan kaldırılmış. İletiniz varsayılan adresinizden gönderilecek. + Özgün gönderici adresi alınamadı. İletiniz varsayılan adresinizden gönderilecek. + Tamam + + Oluştur + İleti, konu olmadan gönderilsin mi? + Evet + Hayır + + Gönderme sorunu + Kapat + E-postanız şu nedenle yazılan adrese gönderilemedi: + Herhangi bir güvenilen anahtar yok: %s + Adres bulunamadı: %s + İletiyi şifrele + Proton Mail kullanmayan kişiler için bu iletiyi şifreleyecek bir parola ayarlayın. + Ayrıntılı bilgi alın + İleti parolası + Parola onayı + Parola ipucu (isteğe bağlı) + 4 ile 21 karakter uzunluğunda + Parola uzunluğu 4 ile 21 karakter arasında olmalıdır + Parola ile onayı aynı değil + Parola ile onayı aynı değil + Parolayı görüntüle + Parolayı gizle + Parolayı uygula + Değişiklikleri kaydet + Parolayı kaldır + Satır arasında yanıtla + + %d üye + %d üye + + diff --git a/mail-composer/presentation/src/main/res/values-uk/strings.xml b/mail-composer/presentation/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..41a1bcd410 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-uk/strings.xml @@ -0,0 +1,108 @@ + + + + Від: + Кому: + Копія: + Прихована копія: + Тема + Написати електронний лист + Закрити редактор + Надіслати повідомлення + Показати інших отримувачів + Недійсна адреса е-пошти + Адреса відправника недійсна + Змінити відправника + Щоб змінити адресу відправника, необхідно передплатити Proton Mail + Неможливо змінити відправника, оскільки не вдалося отримати дані про передплату користувача. Спробуйте ще раз. + Адресу відправника не збережено + Вміст чернетки листа не збережено + Вилучено дублікати отримувачів + Рядок теми не збережено + Не вдалося зберегти отримувачів цієї чернетки + Наразі вміст цієї чернетки недоступний. Редагування перезапише будь-який попередній вміст. + Додати вкладення + Імпортувати з… + Неможливо завантажити інформацію про повідомлення + Оригінальне повідомлення + %s, %s <%s> пише: + OK + Досягнуто обмеження для вкладень + Обмеження розміру для вкладень – %1$s + Збій повторного шифрування вкладення. З міркувань безпеки усі вкладення вилучено. + Вкладення не знайдено. + Не вдалося завантажити оригінальну чернетку повідомлення. + Не вдалося зберегти вкладення. Спробуйте ще раз. + Не вдалося завантажити останню версію цієї чернетки. Будь-які зміни перезапишуть її. + Додати пароль + Вказати термін дії + Термін дії повідомлення + Встановити + Немає + 1 година + 1 день + 3 дні + 1 тиждень + Власний + Не вдалося встановити термін дії + + Термін дії не підтримується + Натомість ми радимо встановити пароль для таких отримувачів: + Скасувати + Все одно надіслати + + Надсилання сповіщення + Надсилання повідомлень з @pm.me є платною функцією. Ваше повідомлення буде надіслано з вашої типової адреси. + Вибрана адреса відправника вимкнена. Ваше повідомлення буде надіслано з типової адреси. + Не вдалося отримати оригінальну адресу відправника. Ваше повідомлення буде надіслано з типової адреси. + OK + + Написати + Надіслати повідомлення без теми? + Так + Ні + + Надсилання звіту про помилку + Закрити + Вашого листа неможливо надіслати на вказану адресу з такої причини: + Немає довірених ключів: %s + Адреса %s не існує + Зашифрувати повідомлення + Встановіть пароль, щоб зашифрувати це повідомлення для користувачів поза Proton Mail. + Докладніше + Пароль повідомлення + Повторіть пароль + Підказка до пароля (необов\'язково) + Довжина від 4 до 21 символу + Пароль повинен мати від 4 до 21 символів + Паролі повинні збігатися + Паролі відрізняються + Показати пароль + Приховати пароль + Застосувати пароль + Зберегти зміни + Вилучити пароль + Відповісти всередині тексту + + %d учасник + %d учасники + %d учасників + %d учасників + + diff --git a/mail-composer/presentation/src/main/res/values-zh-rCN/strings.xml b/mail-composer/presentation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..c35ff6df13 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,105 @@ + + + + 发件人: + 收件人: + 抄送: + 密送: + 主题 + 撰写邮件 + 关闭编辑器 + 发送邮件 + 显示其他收件人 + 电子邮箱地址无效 + 发件人地址无效 + 更改发件地址 + 您需要付费的 Proton Mail 订阅才能更改发件人地址 + 无法更改发件人地址,系统未能获取用户订阅信息。请稍后重试。 + 无法存储此草稿的发件人地址 + 无法存储此草稿正文 + 已移除重复收件人 + 无法存储此草稿的主题 + 无法存储此草稿的收件人信息 + 此草稿的内容暂时无法加载,继续编辑将覆盖此前保存的内容。 + 添加附件 + 导入自… + 无法加载信件信息 + 原始信息 + 在 %s,%s <%s> 写道: + 确定 + 附件大小已达上限 + 附件大小限制为 %1$s + 附件重新加密失败。出于安全原因,所有附件都被删除。 + 找不到附件。 + 无法加载原始草稿信息。 + 无法存储附件,请重试。 + 此草稿的最新版本加载失败。任何更改都会覆盖其内容。 + 添加密码 + 设置有效期 + 邮件有效期 + 设置 + + 1 小时 + 1 天 + 3 天 + 1 周 + 自定义 + 设置有效期失败 + + 不支持设置有效期 + 我们建议为以下收件人设置邮件阅读密码: + 取消 + 仍然发送 + + 提示 + 从 @pm.me 发送邮件是一项付费功能。您的邮件将从您的默认地址发送。 + 所选发件人地址已停用。您的邮件将从您的默认地址发送。 + 无法获取原始发件人地址。您的邮件将从您的默认地址发送。 + 确定 + + 撰写 + 确定要发送无主题的邮件吗? + 确定 + 取消 + + 发送时出错 + 关闭 + 由于以下原因,您的电子邮件无法发送到所输入的地址: + 没有受信任的密钥:%s + 地址不存在:%s + 加密邮件 + 为非 Proton Mail 用户设置阅读此邮件所需的密码。 + 了解详情 + 邮件密码 + 再次输入密码 + 密码提示(可选) + 4 - 21 个字符 + 密码必须介于 4 到 21 个字符之间 + 两次输入的密码必须一致 + 密码不一致 + 显示密码 + 隐藏密码 + 确认密码 + 保存更改 + 移除密码 + 纯文本引用 + + %d 位成员 + + diff --git a/mail-composer/presentation/src/main/res/values-zh-rTW/strings.xml b/mail-composer/presentation/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..badfbe6bca --- /dev/null +++ b/mail-composer/presentation/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,105 @@ + + + + 寄件者: + 收件者: + 副本: + 密件副本: + 主旨 + 撰寫電子郵件 + 關閉撰寫工具 + 傳送郵件訊息 + 顯示其他收件者 + 電子郵件位址無效 + 寄件者電子郵件位址無效 + 變更寄件人 + 您需有付費版的 Proton Mail 訂購授權才可更改寄件人地址 + 因無法取得訂購授權資料無法變更寄件者。請再試一次。 + 未儲存寄件者地址 + 未儲存草稿電子郵件正文 + 已移除重複的收件人 + 未儲存主旨行 + 無法儲存此草稿的收件者 + 目前無法擷取該草稿的內容。編輯將覆寫任何預先存在的內容。 + 新增附件 + 匯入自…… + 無法載入訊息資訊 + 原始訊息 + 在 %s,%s <%s> 寫道: + 確定 + 已達附件上限 + 附件大小上限為 %1$s + 附件重新加密失敗。基於安全考量,所有附件已被移除。 + 找不到附件 + 無法載入原始草稿訊息。 + 無法儲存附件。請再試一次。 + 此草稿的最新版本無法載入。任何變更將覆蓋其內容。 + 新增密碼 + 設定到期時間 + 郵件期限 + 設定 + + 1 小時 + 1 天 + 3 天 + 1 週 + 自訂 + 設定到期日失敗 + + 不支援到期日 + 我們建議為以下收件人設定密碼: + 取消 + 仍要傳送 + + 傳送通知 + 以 @pm.me 傳送郵件乃付費功能。您的郵件將以您預設的電子郵件地址傳送。 + 選取的寄件者電子郵件地址已停用。您的郵件將以您預設的電子郵件地址傳送。 + 無法取得原始寄件者地址。您的訊息將以您預設的電子郵件地址傳送。 + + + 撰寫郵件 + 確定要傳送沒有主旨的郵件嗎? + + + + 傳送錯誤 + 關閉 + 基於以下原因,您的電子郵件無法傳送至已輸入的電子郵件: + 無信任金鑰 : %s + 地址不存在 : %s + 加密郵件 + 替沒有 Protonv Mail 使用者設定一組密碼以進行加密郵件. + 瞭解更多 + 郵件密碼 + 重複輸入密碼 + 密碼提示 (選用) + 長度為 4 至 21 個字元 + 密碼長度必須介乎 4 至 21 個字元 + 密碼必須相符 + 密碼不一致 + 顯示密碼 + 隱藏密碼 + 套用密碼 + 儲存變更 + 移除密碼 + 在內文回覆 + + %d 位成員 + + diff --git a/mail-composer/presentation/src/main/res/values/strings.xml b/mail-composer/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000000..fe93860480 --- /dev/null +++ b/mail-composer/presentation/src/main/res/values/strings.xml @@ -0,0 +1,106 @@ + + + + From: + To: + Cc: + Bcc: + Subject + Compose email + Close composer + Send message + Show other recipients + Email address is invalid + Sender address is invalid + Change sender + You need a paid Proton Mail subscription to change the sender address + Cannot change sender as getting user\'s subscription failed. Try again. + Sender address wasn\'t saved + Draft email body wasn\'t saved + Duplicated recipient(s) removed + Subject line wasn\'t saved + Storing this draft\'s recipients failed + The content of this draft is not available at this time. Editing will override any pre-existing content. + Add attachments + Import from… + Could not load the message\'s information + Original Message + On %s, %s <%s> wrote: + OK + Attachment limit reached + The size limit for attachments is %1$s + Reencryption of attachment failed. For security reasons all attachments got removed. + Attachment not found. + Unable to load the original draft message. + Unable to store the attachment. Please try again. + The latest version of this draft failed to load. Any change will override its content. + Add password + Set expiration time + Message expiration + Set + None + 1 hour + 1 day + 3 days + 1 week + Custom + Setting expiration time failed + + Expiration not supported + We recommend setting up a password instead for the following recipients: + Cancel + Send anyway + + Sending notice + Sending messages from @pm.me is a paid feature. Your message will be sent from your default address. + The selected sender address is disabled. Your message will be sent from your default address. + The original sender address could not be obtained. Your message will be sent from your default address. + OK + + Compose + Send message without subject? + Yes + No + + Sending error + Close + Your email cannot be sent to the address entered due to the following reason: + There are no Trusted Keys: %s + Address does not exist: %s + Encrypt message + Set a password to encrypt this message for non-Proton Mail users. + Learn more + Message password + Repeat password + Password hint (optional) + 4 to 21 characters long + The password must be between 4 and 21 characters + Passwords must match + Passwords do not match + Show password + Hide password + Apply password + Save changes + Remove password + Respond in-line + + %d member + %d members + + diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/EmailValidatorTests.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/EmailValidatorTests.kt new file mode 100644 index 0000000000..6c37c10614 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/EmailValidatorTests.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation + +import ch.protonmail.android.mailcomposer.presentation.ui.form.EmailValidator +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class EmailValidatorTests { + + @Test + fun `Empty email address should not be valid`() { + // Given + val emailAddress = "" + + // When + val isValid = EmailValidator.isValidEmail(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without @ should not be valid`() { + // Given + val emailAddress = "test" + + // When + val isValid = EmailValidator.isValidEmail(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without domain should not be valid`() { + // Given + val emailAddress = "test@" + + // When + val isValid = EmailValidator.isValidEmail(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `Email address without local part should not be valid`() { + // Given + val emailAddress = "@test.com" + + // When + val isValid = EmailValidator.isValidEmail(emailAddress) + + // Then + assertFalse(isValid) + } + + @Test + fun `A valid email address should be valid`() { + // Given + val emailAddress = "a@b.c" + + // When + val isValid = EmailValidator.isValidEmail(emailAddress) + + // Then + assertTrue(isValid) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacadeTest.kt new file mode 100644 index 0000000000..7a2774c9ad --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AddressesFacadeTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationFailure +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class AddressesFacadeTest { + + private val getPrimaryAddress = mockk(relaxed = true) + private val getComposerSenderAddresses = mockk(relaxed = true) + private val validateSenderAddress = mockk(relaxed = true) + + private lateinit var addressesFacade: AddressesFacade + + @BeforeTest + fun setup() { + addressesFacade = AddressesFacade( + getPrimaryAddress, + getComposerSenderAddresses, + validateSenderAddress + ) + } + + @Test + fun `should proxy getPrimaryAddress accordingly (success)`() = runTest { + // Given + val userId = UserId("user-id") + coEvery { getPrimaryAddress.invoke(userId) } returns UserAddressSample.PrimaryAddress.right() + + // When + val senderEmail = addressesFacade.getPrimarySenderEmail(userId).getOrNull() + + // Then + coVerify(exactly = 1) { getPrimaryAddress.invoke(userId) } + assertEquals(UserAddressSample.PrimaryAddress.email, senderEmail?.value) + } + + @Test + fun `should proxy getPrimaryAddress accordingly (failure)`() = runTest { + // Given + val userId = UserId("user-id") + coEvery { getPrimaryAddress.invoke(userId) } returns DataError.Local.Unknown.left() + + // When + val senderEmail = addressesFacade.getPrimarySenderEmail(userId).getOrNull() + + // Then + coVerify(exactly = 1) { getPrimaryAddress.invoke(userId) } + assertNull(senderEmail) + } + + @Test + fun `should proxy getSenderAddresses accordingly`() = runTest { + // When + addressesFacade.getSenderAddresses() + + // Then + coVerify(exactly = 1) { getComposerSenderAddresses.invoke() } + } + + @Test + fun `should proxy validateSenderAddress accordingly (success)`() = runTest { + // Given + val userId = UserId("user-id") + val senderEmail = SenderEmail("sender-email") + val expectedResult = ValidateSenderAddress.ValidationResult.Valid(senderEmail) + + coEvery { validateSenderAddress.invoke(userId, senderEmail) } returns expectedResult.right() + + // When + val result = addressesFacade.validateSenderAddress(userId, senderEmail).getOrNull() + + // Then + coVerify(exactly = 1) { validateSenderAddress.invoke(userId, senderEmail) } + assertEquals(expectedResult, result) + } + + @Test + fun `should proxy validateSenderAddress accordingly (failure with fallback)`() = runTest { + // Given + val userId = UserId("user-id") + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val fallbackEmail = SenderEmail("fallback@pm.me") + val expectedResult = ValidateSenderAddress.ValidationResult.Invalid( + validAddress = fallbackEmail, + invalid = senderEmail, + reason = ValidateSenderAddress.ValidationError.GenericError + ) + + coEvery { validateSenderAddress.invoke(userId, senderEmail) } returns ValidationFailure.CouldNotValidate.left() + coEvery { + getPrimaryAddress.invoke(userId) + } returns UserAddressSample.PrimaryAddress.copy(email = fallbackEmail.value).right() + + // When + val result = addressesFacade.validateSenderAddress(userId, senderEmail).getOrNull() + + // Then + coVerify(exactly = 1) { validateSenderAddress.invoke(userId, senderEmail) } + assertEquals(expectedResult, result) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacadeTest.kt new file mode 100644 index 0000000000..5edd3a652e --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/AttachmentsFacadeTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import android.net.Uri +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAllAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAttachment +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ReEncryptAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreExternalAttachments +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.AttachmentSyncState +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.BeforeTest +import kotlin.test.Test + +internal class AttachmentsFacadeTest { + + private val observeMessageAttachments = mockk(relaxed = true) + private val storeAttachments = mockk(relaxed = true) + private val storeExternalAttachments = mockk(relaxed = true) + private val deleteAttachment = mockk(relaxed = true) + private val deleteAllAttachments = mockk(relaxed = true) + private val reEncryptAttachments = mockk(relaxed = true) + + lateinit var attachmentsFacade: AttachmentsFacade + + @BeforeTest + fun setup() { + attachmentsFacade = AttachmentsFacade( + observeMessageAttachments, + storeAttachments, + storeExternalAttachments, + deleteAttachment, + deleteAllAttachments, + reEncryptAttachments + ) + } + + @Test + fun `should proxy observeMessageAttachments accordingly`() { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + attachmentsFacade.observeMessageAttachments(userId, messageId) + + // Then + verify(exactly = 1) { observeMessageAttachments(userId, messageId) } + } + + @Test + fun `should proxy storeAttachments accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val senderEmail = SenderEmail("sender@email.com") + val uriList = mockk>() + + // When + attachmentsFacade.storeAttachments(userId, messageId, senderEmail, uriList) + + // Then + coVerify(exactly = 1) { storeAttachments(userId, messageId, senderEmail, uriList) } + } + + @Test + fun `should proxy storeExternalAttachments accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val syncState = mockk() + + // When + attachmentsFacade.storeExternalAttachments(userId, messageId, syncState) + + // Then + coVerify(exactly = 1) { storeExternalAttachments(userId, messageId, syncState) } + } + + @Test + fun `should proxy deleteAttachment accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val senderEmail = SenderEmail("sender@email.com") + val attachmentId = AttachmentId("id") + + // When + attachmentsFacade.deleteAttachment(userId, messageId, senderEmail, attachmentId) + + // Then + coVerify(exactly = 1) { deleteAttachment(userId, senderEmail, messageId, attachmentId) } + } + + @Test + fun `should proxy deleteAllAttachments accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val senderEmail = SenderEmail("sender@email.com") + + // When + attachmentsFacade.deleteAllAttachments(userId, senderEmail, messageId) + + // Then + coVerify(exactly = 1) { deleteAllAttachments(userId, senderEmail, messageId) } + } + + @Test + fun `should proxy reEncryptAttachments accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val previousSenderEmail = SenderEmail("prev-sender@email.com") + val newSenderEmail = SenderEmail("new-sender@email.com") + + // When + attachmentsFacade.reEncryptAttachments(userId, messageId, previousSenderEmail, newSenderEmail) + + // Then + coVerify(exactly = 1) { reEncryptAttachments(userId, messageId, previousSenderEmail, newSenderEmail) } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacadeTest.kt new file mode 100644 index 0000000000..9811540130 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/DraftFacadeTest.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploader +import ch.protonmail.android.mailcomposer.domain.usecase.GetDecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.GetLocalMessageDecrypted +import ch.protonmail.android.mailcomposer.domain.usecase.ProvideNewDraftId +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAllFields +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithParentAttachments +import ch.protonmail.android.mailcomposer.presentation.usecase.InjectAddressSignature +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Rule +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull + +internal class DraftFacadeTest { + + private val provideNewDraftId = mockk(relaxed = true) + private val getDecryptedDraftFields = mockk(relaxed = true) + private val getLocalMessageDecrypted = mockk(relaxed = true) + private val parentMessageToDraftFields = mockk(relaxed = true) + private val storeDraftWithAllFields = mockk(relaxed = true) + private val storeDraftWithParentAttachments = mockk(relaxed = true) + private val injectAddressSignature = mockk(relaxed = true) + private val draftUploader = mockk(relaxed = true) + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + lateinit var draftFacade: DraftFacade + + @BeforeTest + fun setup() { + draftFacade = DraftFacade( + provideNewDraftId, + getDecryptedDraftFields, + getLocalMessageDecrypted, + parentMessageToDraftFields, + storeDraftWithAllFields, + storeDraftWithParentAttachments, + injectAddressSignature, + draftUploader, + testDispatcher + ) + } + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `should proxy provideNewDraftId accordingly`() { + // When + draftFacade.provideNewDraftId() + + // Then + verify(exactly = 1) { provideNewDraftId.invoke() } + } + + @Test + fun `should proxy getDecryptedDraftFields accordingly`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedMessageId = MessageId("message-id") + + // When + draftFacade.getDecryptedDraftFields(expectedUserId, expectedMessageId) + + // Then + coVerify(exactly = 1) { getDecryptedDraftFields.invoke(expectedUserId, expectedMessageId) } + } + + @Test + fun `should proxy storeDraft accordingly`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedDraftMessageId = MessageId("message-id") + val expectedFields = mockk() + val draftAction = DraftAction.Reply(parentId = MessageId("message-id-parent")) + + // When + draftFacade.storeDraft(expectedUserId, expectedDraftMessageId, expectedFields, draftAction) + + // Then + coVerify(exactly = 1) { + storeDraftWithAllFields(expectedUserId, expectedDraftMessageId, expectedFields, draftAction) + } + } + + @Test + fun `should proxy storeDraftWithParentAttachments accordingly`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedMessageId = MessageId("message-id") + val expectedParentMessage = mockk() + val expectedSenderEmail = SenderEmail("sender@email.com") + val expectedDraftAction = DraftAction.Compose + + // When + draftFacade.storeDraftWithParentAttachments( + expectedUserId, expectedMessageId, expectedParentMessage, expectedSenderEmail, expectedDraftAction + ) + + // Then + coVerify(exactly = 1) { + storeDraftWithParentAttachments( + expectedUserId, expectedMessageId, expectedParentMessage, expectedSenderEmail, expectedDraftAction + ) + } + } + + @Test + fun `should proxy start continuous upload properly`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedMessageId = MessageId("message-id") + val expectedAction = DraftAction.Compose + val expectedScope = mockk() + + // When + draftFacade.startContinuousUpload( + expectedUserId, + expectedMessageId, + expectedAction, + expectedScope + ) + + // Then + coVerify(exactly = 1) { + draftUploader.startContinuousUpload( + expectedUserId, + expectedMessageId, + expectedAction, + expectedScope + ) + } + } + + @Test + fun `should proxy stop continuous upload properly`() = runTest { + + // When + draftFacade.stopContinuousUpload() + + // Then + coVerify(exactly = 1) { + draftUploader.stopContinuousUpload() + } + } + + @Test + fun `should proxy force upload properly`() = runTest { + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + draftFacade.forceUpload(userId, messageId) + + // Then + coVerify(exactly = 1) { + draftUploader.upload(userId, messageId) + } + } + + @Test + fun `should proxy injectAddressSignature accordingly`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedDraftBody = DraftBody("body") + val expectedSenderEmail = SenderEmail("sender@email.com") + val expectedPreviousSenderEmail = null + + // When + draftFacade.injectAddressSignature( + expectedUserId, + expectedDraftBody, + expectedSenderEmail, + expectedPreviousSenderEmail + ) + + // Then + coVerify(exactly = 1) { + injectAddressSignature(expectedUserId, expectedDraftBody, expectedSenderEmail, expectedPreviousSenderEmail) + } + } + + @Test + fun `should return null if it can't get the local message from the parent id`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedMessageId = MessageId("message-id") + val expectedAction = DraftAction.Compose + val expectedMessageDecrypted = DataError.Local.NoDataCached.left() + coEvery { getLocalMessageDecrypted.invoke(expectedUserId, expectedMessageId) } returns expectedMessageDecrypted + + // When + val result = draftFacade.parentMessageToDraftFields(expectedUserId, expectedMessageId, expectedAction) + + // Then + coVerify(exactly = 1) { getLocalMessageDecrypted.invoke(expectedUserId, expectedMessageId) } + assertNull(result) + } + + @Test + fun `should return null if it can't get the fields from the parent message`() = runTest { + // Given + val expectedUserId = UserId("user-id") + val expectedMessageId = MessageId("message-id") + val expectedAction = DraftAction.Compose + val expectedMessageDecrypted = mockk().right() + val expectedDraftFields = DataError.Local.Unknown.left() + coEvery { getLocalMessageDecrypted.invoke(expectedUserId, expectedMessageId) } returns expectedMessageDecrypted + coEvery { + parentMessageToDraftFields.invoke( + expectedUserId, + expectedMessageDecrypted.getOrNull()!!, + expectedAction + ) + } returns expectedDraftFields + + // When + val result = draftFacade.parentMessageToDraftFields(expectedUserId, expectedMessageId, expectedAction) + + // Then + coVerify(exactly = 1) { getLocalMessageDecrypted.invoke(expectedUserId, expectedMessageId) } + coVerify(exactly = 1) { + parentMessageToDraftFields.invoke( + expectedUserId, + expectedMessageDecrypted.getOrNull()!!, + expectedAction + ) + } + assertNull(result) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacadeTest.kt new file mode 100644 index 0000000000..dd0566a28b --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageAttributesFacadeTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessageExpirationTime +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.Test +import kotlin.test.BeforeTest +import kotlin.time.Duration.Companion.minutes + +internal class MessageAttributesFacadeTest { + + private val observeMessagePassword = mockk(relaxed = true) + private val observeMessageExpiration = mockk(relaxed = true) + private val saveMessageExpirationTime = mockk(relaxed = true) + + private lateinit var messageAttributesFacade: MessageAttributesFacade + + @BeforeTest + fun setup() { + messageAttributesFacade = MessageAttributesFacade( + observeMessagePassword, + observeMessageExpiration, + saveMessageExpirationTime + ) + } + + @Test + fun `should proxy observeMessagePassword accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + messageAttributesFacade.observeMessagePassword(userId, messageId) + + // Then + coVerify(exactly = 1) { observeMessagePassword(userId, messageId) } + } + + @Test + fun `should proxy observeMessageExpiration accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + messageAttributesFacade.observeMessageExpiration(userId, messageId) + + // Then + coVerify(exactly = 1) { observeMessageExpiration(userId, messageId) } + } + + @Test + fun `should proxy saveMessageExpiration accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val senderEmail = SenderEmail("sender@email.com") + val expiration = 10.minutes + + // When + messageAttributesFacade.saveMessageExpiration(userId, messageId, senderEmail, expiration) + + // Then + coVerify(exactly = 1) { saveMessageExpirationTime(userId, messageId, senderEmail, expiration) } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacadeTest.kt new file mode 100644 index 0000000000..d080779ddd --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageContentFacadeTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.presentation.usecase.ConvertHtmlToPlainText +import ch.protonmail.android.mailcomposer.presentation.usecase.StyleQuotedHtml +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.BeforeTest + +internal class MessageContentFacadeTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + private val convertHtmlToPlainText = mockk(relaxed = true) + private val styleQuotedHtml = mockk(relaxed = true) + + private lateinit var messageContentFacade: MessageContentFacade + + @BeforeTest + fun setup() { + messageContentFacade = MessageContentFacade( + convertHtmlToPlainText, + styleQuotedHtml, + testDispatcher + ) + } + + @Test + fun `should proxy convertHtmlToPlainText accordingly`() = runTest { + // Given + val html = "some-html" + + // Then + messageContentFacade.convertHtmlToPlainText(html) + + // When + coVerify { convertHtmlToPlainText.invoke(html) } + } + + @Test + fun `should proxy styleQuotedHtml accordingly`() = runTest { + // Given + val quotedHtml = OriginalHtmlQuote("original-text-html") + + // Then + messageContentFacade.styleQuotedHtml(quotedHtml) + + // When + coVerify { styleQuotedHtml.invoke(quotedHtml) } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacadeTest.kt new file mode 100644 index 0000000000..7b746451ae --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageParticipantsFacadeTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.usecase.GetExternalRecipients +import ch.protonmail.android.mailcomposer.presentation.mapper.ComposerParticipantMapper +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.BeforeTest +import kotlin.test.Test + +internal class MessageParticipantsFacadeTest { + + private val observePrimaryUserId = mockk(relaxed = true) + private val participantMapper = mockk(relaxed = true) + private val getExternalRecipients = mockk(relaxed = true) + + private lateinit var messageParticipantsFacade: MessageParticipantsFacade + + @BeforeTest + fun setup() { + messageParticipantsFacade = MessageParticipantsFacade( + observePrimaryUserId, + participantMapper, + getExternalRecipients + ) + } + + @Test + fun `should proxy observePrimaryUserId accordingly`() { + // When + messageParticipantsFacade.observePrimaryUserId() + + // Then + verify(exactly = 1) { observePrimaryUserId.invoke() } + } + + @Test + fun `should proxy mapToParticipant accordingly`() = runTest { + // Given + val recipient = RecipientUiModel.Valid("test@example.com") + + // When + messageParticipantsFacade.mapToParticipant(recipient) + + // Then + coVerify(exactly = 1) { participantMapper.recipientUiModelToParticipant(recipient) } + } + + @Test + fun `should proxy getExternalRecipients accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val recipientsTo = RecipientsTo(mockk()) + val recipientsCc = RecipientsCc(mockk()) + val recipientsBcc = RecipientsBcc(mockk()) + + // When + messageParticipantsFacade.getExternalRecipients(userId, recipientsTo, recipientsCc, recipientsBcc) + + // Then + coVerify(exactly = 1) { getExternalRecipients(userId, recipientsTo, recipientsCc, recipientsBcc) } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacadeTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacadeTest.kt new file mode 100644 index 0000000000..0bc8ecde89 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/facade/MessageSendingFacadeTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.facade + +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.ClearMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.SendMessage +import ch.protonmail.android.mailcomposer.presentation.usecase.FormatMessageSendingError +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import kotlin.test.BeforeTest +import kotlin.test.Test + +internal class MessageSendingFacadeTest { + + private val sendMessage = mockk(relaxed = true) + private val observeSendingErrors = mockk(relaxed = true) + private val formatMessageSendingError = mockk(relaxed = true) + private val clearMessageSendingError = mockk(relaxed = true) + + private lateinit var messageSendingFacade: MessageSendingFacade + + @BeforeTest + fun setup() { + messageSendingFacade = MessageSendingFacade( + sendMessage, + observeSendingErrors, + formatMessageSendingError, + clearMessageSendingError + ) + } + + @Test + fun `should proxy sendMessage accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val fields = mockk() + val action = DraftAction.Compose + + // When + messageSendingFacade.sendMessage(userId, messageId, fields, action) + + // Then + coVerify(exactly = 1) { sendMessage(userId, messageId, fields, action) } + } + + @Test + fun `should proxy observeAndFormatSendingErrors accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + messageSendingFacade.observeAndFormatSendingErrors(userId, messageId) + + // Then + coVerify(exactly = 1) { observeSendingErrors(userId, messageId) } + } + + @Test + fun `should proxy clearMessageSendingError accordingly`() = runTest { + // Given + val userId = UserId("user-id") + val messageId = MessageId("message-id") + + // When + messageSendingFacade.clearMessageSendingError(userId, messageId) + + // Then + coVerify(exactly = 1) { clearMessageSendingError(userId, messageId) } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapperTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapperTest.kt new file mode 100644 index 0000000000..dd61ca4b04 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ComposerParticipantMapperTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.testdata.contact.ContactTestData +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.entity.UserId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class ComposerParticipantMapperTest { + + private val observePrimaryUserId = mockk() + private val searchContacts = mockk() + private val searchDeviceContacts = mockk() + + private lateinit var participantMapper: ComposerParticipantMapper + + private val validRecipient = RecipientUiModel.Valid("test@example.com") + + @BeforeTest + fun setup() { + participantMapper = ComposerParticipantMapper( + observePrimaryUserId, + searchContacts, + searchDeviceContacts + ) + } + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `should return a participant with name = address when unable to get the primary user id`() = runTest { + // Given + every { observePrimaryUserId.invoke() } returns flowOf() + + val expectedParticipant = Participant( + address = validRecipient.address, + name = validRecipient.address + ) + + // When + val actual = participantMapper.recipientUiModelToParticipant(validRecipient) + + // Then + assertEquals(expectedParticipant, actual) + coVerify { searchContacts wasNot called } + coVerify { searchDeviceContacts wasNot called } + } + + @Test + fun `should return a participant with name = address when address is unknown across all sources`() = runTest { + // Given + expectValidUserId() + expectNoProtonContact(validRecipient.address) + expectNoDeviceContact(validRecipient.address) + + val expectedParticipant = Participant( + address = validRecipient.address, + name = validRecipient.address + ) + + // When + val actual = participantMapper.recipientUiModelToParticipant(validRecipient) + + // Then + assertEquals(expectedParticipant, actual) + coVerifySequence { + searchContacts.invoke(userId, validRecipient.address) + searchDeviceContacts.invoke(validRecipient.address) + } + } + + @Test + fun `should return a participant with the account contacts details when found`() = runTest { + // Given + expectValidUserId() + expectProtonContact(validRecipient.address, "contact") + + val expectedParticipant = Participant( + address = validRecipient.address, + name = "contact" + ) + + // When + val actual = participantMapper.recipientUiModelToParticipant(validRecipient) + + // Then + assertEquals(expectedParticipant, actual) + coVerify(exactly = 1) { searchContacts.invoke(userId, validRecipient.address) } + coVerify { searchDeviceContacts wasNot called } + } + + @Test + fun `should hit the cache when querying the same contact email twice`() = runTest { + // Given + expectValidUserId() + expectProtonContact(validRecipient.address, "contact") + + val expectedParticipant = Participant( + address = validRecipient.address, + name = "contact" + ) + + // When + val firstQueryResult = participantMapper.recipientUiModelToParticipant(validRecipient) + val secondQueryResult = participantMapper.recipientUiModelToParticipant(validRecipient) + + // Then + assertEquals(expectedParticipant, firstQueryResult) + assertEquals(expectedParticipant, secondQueryResult) + coVerify(exactly = 1) { searchContacts.invoke(userId, validRecipient.address) } + coVerify { searchDeviceContacts wasNot called } + } + + @Test + fun `should query the device contacts when the email is not known in the account contacts`() = runTest { + // Given + expectValidUserId() + expectNoProtonContact(validRecipient.address) + expectDeviceContact(validRecipient.address, "contact") + + val expectedParticipant = Participant( + address = validRecipient.address, + name = "contact" + ) + + // When + val actual = participantMapper.recipientUiModelToParticipant(validRecipient) + + // Then + assertEquals(expectedParticipant, actual) + coVerifySequence { + searchContacts.invoke(userId, validRecipient.address) + searchDeviceContacts.invoke(validRecipient.address) + } + } + + private fun expectValidUserId() { + every { observePrimaryUserId.invoke() } returns flowOf(userId) + } + + private fun expectProtonContact(address: String, name: String) { + + val contact = ContactTestData.buildContactWith( + userId, + contactEmails = listOf(ContactTestData.buildContactEmailWith(userId, name = name, address = address)) + ) + + coEvery { searchContacts(userId, address) } returns flowOf(listOf(contact).right()) + } + + private fun expectNoProtonContact(address: String) { + coEvery { searchContacts(userId, address) } returns flowOf(emptyList().right()) + } + + private fun expectDeviceContact(address: String, name: String) { + coEvery { searchDeviceContacts(address) } returns listOf(DeviceContact(name, address)).right() + } + + private fun expectNoDeviceContact(address: String) { + coEvery { searchDeviceContacts(address) } returns emptyList().right() + } + + private companion object { + + val userId = UserId("random-id") + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapperTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapperTest.kt new file mode 100644 index 0000000000..f1bc2316b4 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/ParticipantMapperTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.testdata.contact.ContactIdTestData +import ch.protonmail.android.testdata.user.UserIdTestData +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.contact.domain.entity.ContactEmailId +import kotlin.test.Test +import kotlin.test.assertEquals + +class ParticipantMapperTest { + + private val recipientUiModel = RecipientUiModel.Valid("test1@protonmail.com") + private val recipientUiModelNotInContacts = RecipientUiModel.Valid("test_not_in_contacts@protonmail.com") + private val recipientUiModelEmptyNames = RecipientUiModel.Valid("test3@protonmail.com") + private val participantMapper = ParticipantMapper() + + private val contacts = listOf( + Contact( + UserIdTestData.userId, ContactIdTestData.contactId1, "first contact", + listOf( + ContactEmail( + UserIdTestData.userId, + ContactEmailId("contact email id 1"), + "First name from contact email", + "test1+alias@protonmail.com", + 0, + 0, + ContactIdTestData.contactId1, + "test1@protonmail.com", + emptyList(), + true, + lastUsedTime = 0 + ) + ) + ), + Contact( + UserIdTestData.userId, ContactIdTestData.contactId2, "", + listOf( + ContactEmail( + UserIdTestData.userId, + ContactEmailId("contact email id 2"), + "", + "test2@protonmail.com", + 0, + 0, + ContactIdTestData.contactId2, + "test2@protonmail.com", + emptyList(), + false, + lastUsedTime = 0 + ), + ContactEmail( + UserIdTestData.userId, + ContactEmailId("contact email id 3"), + "", + "test3@protonmail.com", + 0, + 0, + ContactIdTestData.contactId1, + "test3@protonmail.com", + emptyList(), + false, + lastUsedTime = 0 + ) + ) + ) + ) + + @Test + fun `valid recipient ui model is mapped to participant, name from ContactEmail`() { + // Given + val expectedResult = Participant( + address = "test1@protonmail.com", + name = "First name from contact email", + isProton = true + ) + + // When + val result = participantMapper.recipientUiModelToParticipant(recipientUiModel, contacts) + + // Then + assertEquals(expectedResult, result) + } + + @Test + fun `valid recipient ui model is mapped to participant, Contact names are empty, fallback name to email address`() { + // Given + val expectedResult = Participant( + address = "test_not_in_contacts@protonmail.com", + name = "test_not_in_contacts@protonmail.com", + isProton = false + ) + + // When + val result = participantMapper.recipientUiModelToParticipant(recipientUiModelNotInContacts, contacts) + + // Then + assertEquals(expectedResult, result) + } + + @Test + fun `valid recipient ui model is mapped to participant, not found in Contacts, fallback name to email address`() { + // Given + val expectedResult = Participant( + address = "test3@protonmail.com", + name = "test3@protonmail.com", + isProton = false + ) + + // When + val result = participantMapper.recipientUiModelToParticipant(recipientUiModelEmptyNames, contacts) + + // Then + assertEquals(expectedResult, result) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapperTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapperTest.kt new file mode 100644 index 0000000000..ab99f4fc02 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/RecipientUiModelMapperTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper + +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class RecipientUiModelMapperTest { + + private val validAddress1 = "noreply1@proton.me" + private val validAddress2 = "noreply2@proton.me" + private val invalidAddress = "proton+me" + + @Test + fun `should map from raw values (valid)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Valid(validAddress1), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromRawValue(listOf(validAddress1, validAddress2)) + + // Then + assertEquals(expectedRecipientUiModel, actual) + } + + @Test + fun `should map from raw values (invalid)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Invalid(invalidAddress), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromRawValue(listOf(invalidAddress, validAddress2)) + + // Then + assertEquals(expectedRecipientUiModel, actual) + } + + @Test + fun `should map from raw values (mixed)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Valid(validAddress1), + RecipientUiModel.Invalid(invalidAddress), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromRawValue( + listOf( + validAddress1, + invalidAddress, + validAddress2 + ) + ) + + // Then + assertEquals(expectedRecipientUiModel, actual) + } + + @Test + fun `should map from participants (valid)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Valid(validAddress1), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromParticipants( + listOf( + Participant(validAddress1, validAddress1.reversed()), + Participant(validAddress2, validAddress2.reversed()) + ) + ) + + // Then + assertEquals( + expectedRecipientUiModel, actual + ) + } + + @Test + fun `should map from participants (invalid)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Invalid(invalidAddress), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromParticipants( + listOf( + Participant(invalidAddress, invalidAddress.reversed()), + Participant(validAddress2, validAddress2.reversed()) + ) + ) + + // Then + assertEquals(expectedRecipientUiModel, actual) + } + + @Test + fun `should map from participants (mixed)`() { + // Given + val expectedRecipientUiModel = listOf( + RecipientUiModel.Valid(validAddress1), + RecipientUiModel.Invalid(invalidAddress), + RecipientUiModel.Valid(validAddress2) + ) + + // When + val actual = RecipientUiModelMapper.mapFromParticipants( + listOf( + Participant(validAddress1, validAddress1.reversed()), + Participant(invalidAddress, invalidAddress.reversed()), + Participant(validAddress2, validAddress2.reversed()) + ) + ) + + // Then + assertEquals(expectedRecipientUiModel, actual) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AccessoriesEventTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AccessoriesEventTest.kt new file mode 100644 index 0000000000..451b6c4b35 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AccessoriesEventTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.effects + +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.presentation.model.operations.AccessoriesEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AccessoriesStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailmessage.domain.model.MessageId +import me.proton.core.domain.entity.UserId +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +@RunWith(Parameterized::class) +internal class AccessoriesEventTest( + @Suppress("unused") private val testName: String, + private val effect: AccessoriesEvent, + private val expectedModification: ComposerStateModifications +) { + + @Test + fun `should map to the correct modification`() { + val actualModification = effect.toStateModifications() + assertEquals(expectedModification, actualModification) + } + + companion object { + + private val password = MessagePassword(UserId("123"), MessageId("456"), "password", null) + private val expiration = MessageExpirationTime(UserId("123"), MessageId("456"), 2.hours) + private val nullExpiration = null + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "OnPasswordChanged to modification", + AccessoriesEvent.OnPasswordChanged(password), + ComposerStateModifications( + accessoriesModification = AccessoriesStateModification.MessagePasswordUpdated( + password + ) + ) + ), + arrayOf( + "OnExpirationChanged (not null( to modification", + AccessoriesEvent.OnExpirationChanged(expiration), + ComposerStateModifications( + accessoriesModification = AccessoriesStateModification.MessageExpirationUpdated( + expiration.expiresIn + ) + ) + ), + arrayOf( + "OnExpirationChanged (not null( to modification", + AccessoriesEvent.OnExpirationChanged(nullExpiration), + ComposerStateModifications( + accessoriesModification = AccessoriesStateModification.MessageExpirationUpdated(Duration.ZERO) + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AttachmentsEventTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AttachmentsEventTest.kt new file mode 100644 index 0000000000..9e1e98a58f --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/AttachmentsEventTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.effects + +import ch.protonmail.android.mailcomposer.presentation.model.operations.AttachmentsEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AttachmentsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class AttachmentsEventTest( + @Suppress("unused") private val testName: String, + private val effect: AttachmentsEvent, + private val expectedModification: ComposerStateModifications +) { + + @Test + fun `should map to the correct modification`() { + val actualModification = effect.toStateModifications() + assertEquals(expectedModification, actualModification) + } + + companion object { + + private val attachmentsList = listOf(mockk()) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "OnListChanged to modification", + AttachmentsEvent.OnListChanged(attachmentsList), + ComposerStateModifications( + attachmentsModification = AttachmentsStateModification.ListUpdated(attachmentsList) + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/CompositeEventTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/CompositeEventTest.kt new file mode 100644 index 0000000000..1b3cb19153 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/CompositeEventTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.effects + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.model.operations.CompositeEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AccessoriesStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.BottomSheetEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ConfirmationsEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ContentEffectsStateModifications +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +@RunWith(Parameterized::class) +internal class CompositeEventTest( + @Suppress("unused") private val testName: String, + private val effect: CompositeEvent, + private val expectedModification: ComposerStateModifications +) { + + @Test + fun `should map to the correct modification`() { + val actualModification = effect.toStateModifications() + assertEquals(expectedModification, actualModification) + } + + companion object { + + private val senderEmail = SenderEmail("sender@email.com") + private val draftContentReady = CompositeEvent.DraftContentReady( + senderEmail = "sender@email.com", + isDataRefreshed = false, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(senderEmail), + quotedHtmlContent = null, + shouldRestrictWebViewHeight = false, + forceBodyFocus = false + ) + + private val senderAddresses: List = listOf(mockk()) + private val expiration = 2.hours + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "DraftContentReady to modification", + draftContentReady, + ComposerStateModifications( + mainModification = MainStateModification.OnDraftReady( + sender = senderEmail.value, + quotedHtmlContent = null, + shouldRestrictWebViewHeight = false + ), + effectsModification = ContentEffectsStateModifications.DraftContentReady( + draftContentReady.senderValidationResult, + draftContentReady.isDataRefreshed, + draftContentReady.forceBodyFocus + ) + ) + ), + arrayOf( + "SenderAddressesListReady to modification", + CompositeEvent.SenderAddressesListReady(senderAddresses), + ComposerStateModifications( + mainModification = MainStateModification.SendersListReady(senderAddresses), + effectsModification = BottomSheetEffectsStateModification.ShowBottomSheet + ) + ), + arrayOf( + "OnSendWithEmptySubject to modification", + CompositeEvent.OnSendWithEmptySubject, + ComposerStateModifications( + mainModification = MainStateModification.UpdateLoading(ComposerState.LoadingType.None), + effectsModification = ConfirmationsEffectsStateModification.SendNoSubjectConfirmationRequested + ) + ), + arrayOf( + "SetExpirationDismissed to modification", + CompositeEvent.SetExpirationDismissed(expiration), + ComposerStateModifications( + effectsModification = BottomSheetEffectsStateModification.HideBottomSheet, + accessoriesModification = AccessoriesStateModification.MessageExpirationUpdated(expiration) + ) + ), + arrayOf( + "UserChangedSender to modification", + CompositeEvent.UserChangedSender(senderEmail), + ComposerStateModifications( + mainModification = MainStateModification.UpdateSender(senderEmail), + effectsModification = BottomSheetEffectsStateModification.HideBottomSheet + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/EffectsEventTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/EffectsEventTest.kt new file mode 100644 index 0000000000..99716ec8b2 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/EffectsEventTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.effects + +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.presentation.model.operations.EffectsEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.BottomSheetEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.CompletionEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ConfirmationsEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ContentEffectsStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.LoadingError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.RecoverableError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.UnrecoverableError +import ch.protonmail.android.mailmessage.domain.model.Recipient +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class EffectsEventTest( + @Suppress("unused") private val testName: String, + private val effect: EffectsEvent, + private val expectedModification: ComposerStateModifications +) { + + @Test + fun `should map to the correct modification`() { + val actualModification = effect.toStateModifications() + assertEquals(expectedModification, actualModification) + } + + companion object { + + private val attachmentError: StoreDraftWithAttachmentError = mockk() + private val externalRecipients: List = mockk() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "OnDraftLoadingFailed to modification", + EffectsEvent.DraftEvent.OnDraftLoadingFailed, + ComposerStateModifications(effectsModification = LoadingError.DraftContent) + ), + arrayOf( + "OnParentLoadingFailed to modification", + EffectsEvent.LoadingEvent.OnParentLoadingFailed, + ComposerStateModifications(effectsModification = UnrecoverableError.ParentMessageMetadata) + ), + arrayOf( + "OnSenderAddressLoadingFailed to modification", + EffectsEvent.LoadingEvent.OnSenderAddressLoadingFailed, + ComposerStateModifications(effectsModification = UnrecoverableError.InvalidSenderAddress) + ), + arrayOf( + "AttachmentEventError to modification", + EffectsEvent.AttachmentEvent.Error(attachmentError), + ComposerStateModifications(effectsModification = RecoverableError.AttachmentsStore(attachmentError)) + ), + arrayOf( + "AttachmentEventReEncryptionError to modification", + EffectsEvent.AttachmentEvent.ReEncryptError, + ComposerStateModifications(effectsModification = RecoverableError.ReEncryptAttachment) + ), + arrayOf( + "AttachmentEventOnAddRequest to modification", + EffectsEvent.AttachmentEvent.OnAddRequest, + ComposerStateModifications( + effectsModification = ContentEffectsStateModifications.OnAddAttachmentRequested + ) + ), + arrayOf( + "ComposerControlEvent OnCloseRequest (true) to modification", + EffectsEvent.ComposerControlEvent.OnCloseRequest(true), + ComposerStateModifications(effectsModification = CompletionEffectsStateModification.CloseComposer(true)) + ), + arrayOf( + "ComposerControlEvent OnCloseRequest (false) to modification", + EffectsEvent.ComposerControlEvent.OnCloseRequest(false), + ComposerStateModifications( + effectsModification = CompletionEffectsStateModification.CloseComposer(false) + ) + ), + arrayOf( + "ComposerControlEvent OnComposerRestored to modification", + EffectsEvent.ComposerControlEvent.OnComposerRestored, + ComposerStateModifications( + effectsModification = CompletionEffectsStateModification.CloseComposer(false) + ) + ), + arrayOf( + "ErrorEvent OnSenderChangeFreeUserError to modification", + EffectsEvent.ErrorEvent.OnSenderChangeFreeUserError, + ComposerStateModifications(effectsModification = RecoverableError.SenderChange.FreeUser) + ), + arrayOf( + "ErrorEvent OnSenderChangePermissionsError to modification", + EffectsEvent.ErrorEvent.OnSenderChangePermissionsError, + ComposerStateModifications(effectsModification = RecoverableError.SenderChange.UnknownPermissions) + ), + arrayOf( + "ErrorEvent OnSetExpirationError to modification", + EffectsEvent.ErrorEvent.OnSetExpirationError, + ComposerStateModifications(effectsModification = RecoverableError.Expiration) + ), + arrayOf( + "SendEvent OnCancelSendNoSubject to modification", + EffectsEvent.SendEvent.OnCancelSendNoSubject, + ComposerStateModifications( + effectsModification = ConfirmationsEffectsStateModification.CancelSendNoSubject + ) + ), + arrayOf( + "SendEvent OnCancelSendNoSubject to modification", + EffectsEvent.SendEvent.OnSendExpiringToExternalRecipients(externalRecipients), + ComposerStateModifications( + effectsModification = ConfirmationsEffectsStateModification.ShowExternalExpiringRecipients( + externalRecipients + ) + ) + ), + arrayOf( + "SendEvent OnSendMessage to modification", + EffectsEvent.SendEvent.OnSendMessage, + ComposerStateModifications( + effectsModification = CompletionEffectsStateModification.SendMessage.SendAndExit + ) + ), + arrayOf( + "SendEvent OnOfflineSendMessage to modification", + EffectsEvent.SendEvent.OnOfflineSendMessage, + ComposerStateModifications( + effectsModification = CompletionEffectsStateModification.SendMessage.SendAndExitOffline + ) + ), + arrayOf( + "SendEvent OnSendingError to modification", + EffectsEvent.SendEvent.OnSendingError("message"), + ComposerStateModifications(effectsModification = RecoverableError.SendingFailed("message")) + ), + arrayOf( + "SetExpirationReady to modification", + EffectsEvent.SetExpirationReady, + ComposerStateModifications( + effectsModification = BottomSheetEffectsStateModification.ShowBottomSheet + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/MainEventTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/MainEventTest.kt new file mode 100644 index 0000000000..23e65631b9 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/effects/MainEventTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.effects + +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.operations.MainEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.ComposerStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class MainEventTest( + @Suppress("unused") private val testName: String, + private val effect: MainEvent, + private val expectedModification: ComposerStateModifications +) { + + @Test + fun `should map to the correct modification`() { + val actualModification = effect.toStateModifications() + assertEquals(expectedModification, actualModification) + } + + companion object { + private val senderEmail = SenderEmail("sender-email@proton.me") + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "InitialLoadingToggled to modification", + MainEvent.InitialLoadingToggled, + ComposerStateModifications( + mainModification = MainStateModification.UpdateLoading(ComposerState.LoadingType.Initial) + ) + ), + arrayOf( + "LoadingDismissed to modification", + MainEvent.LoadingDismissed, + ComposerStateModifications( + mainModification = MainStateModification.UpdateLoading(ComposerState.LoadingType.None) + ) + ), + arrayOf( + "CoreLoadingToggled to modification", + MainEvent.CoreLoadingToggled, + ComposerStateModifications( + mainModification = MainStateModification.UpdateLoading(ComposerState.LoadingType.Save) + ) + ), + + arrayOf( + "RecipientsChanged (submittable) to modification", + MainEvent.RecipientsChanged(areSubmittable = true), + ComposerStateModifications( + mainModification = MainStateModification.UpdateSubmittable(true) + ) + ), + arrayOf( + "RecipientsChanged (non submittable) to modification", + MainEvent.RecipientsChanged(areSubmittable = false), + ComposerStateModifications( + mainModification = MainStateModification.UpdateSubmittable(false) + ) + ), + arrayOf( + "SenderChanged to modification", + MainEvent.SenderChanged(newSender = senderEmail), + ComposerStateModifications( + mainModification = MainStateModification.UpdateSender(senderEmail) + ) + ), + arrayOf( + "OnQuotedHtmlRemoved to modification", + MainEvent.OnQuotedHtmlRemoved, + ComposerStateModifications( + mainModification = MainStateModification.RemoveHtmlQuotedText + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AccessoriesStateModificationTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AccessoriesStateModificationTest.kt new file mode 100644 index 0000000000..e6b9e79690 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AccessoriesStateModificationTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.modifications + +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AccessoriesStateModification +import io.mockk.mockk +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +@RunWith(Parameterized::class) +internal class AccessoriesStateModificationTest( + @Suppress("unused") private val testName: String, + private val initialState: ComposerState.Accessories, + private val modification: AccessoriesStateModification, + private val expectedState: ComposerState.Accessories +) { + + @Test + fun `should apply the modification`() { + val updatedState = modification.apply(initialState) + assertEquals(expectedState, updatedState) + } + + companion object { + + private val initialState = ComposerState.Accessories.initial() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "set the password from initial state", + initialState, + AccessoriesStateModification.MessagePasswordUpdated(mockk()), + initialState.copy(isMessagePasswordSet = true) + ), + arrayOf( + "remove the password when already set", + initialState.copy(isMessagePasswordSet = true), + AccessoriesStateModification.MessagePasswordUpdated(null), + initialState.copy(isMessagePasswordSet = false) + ), + arrayOf( + "set the expiration from initial state", + initialState, + AccessoriesStateModification.MessageExpirationUpdated(1.hours), + initialState.copy(messageExpiresIn = 1.hours) + ), + arrayOf( + "update the expiration from an existing value", + initialState.copy(messageExpiresIn = 30.minutes), + AccessoriesStateModification.MessageExpirationUpdated(1.days), + initialState.copy(messageExpiresIn = 1.days) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AttachmentsStateModificationTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AttachmentsStateModificationTest.kt new file mode 100644 index 0000000000..9cd7cacb71 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/AttachmentsStateModificationTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.modifications + +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.AttachmentsStateModification +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper2 +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class AttachmentsStateModificationTest( + @Suppress("unused") private val testName: String, + private val initialState: ComposerState.Attachments, + private val modification: AttachmentsStateModification.ListUpdated, + private val expectedState: ComposerState.Attachments +) { + + @Test + fun `should apply the modification`() { + val updatedState = modification.apply(initialState) + assertEquals(expectedState, updatedState) + } + + companion object { + + private val initialState = ComposerState.Attachments.initial() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "empty attachments list", + initialState, + AttachmentsStateModification.ListUpdated(emptyList()), + ComposerState.Attachments( + uiModel = AttachmentGroupUiModel(limit = NO_ATTACHMENT_LIMIT, attachments = emptyList()) + ) + ), + arrayOf( + "single attachment list", + initialState, + AttachmentsStateModification.ListUpdated(listOf(MessageAttachmentSample.invoice)), + ComposerState.Attachments( + uiModel = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = listOf(AttachmentUiModelMapper2.toUiModel(MessageAttachmentSample.invoice, true)) + ) + ) + ), + arrayOf( + "multiple attachments list", + initialState, + AttachmentsStateModification.ListUpdated( + listOf(MessageAttachmentSample.invoice, MessageAttachmentSample.document) + ), + ComposerState.Attachments( + uiModel = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = listOf( + AttachmentUiModelMapper2.toUiModel(MessageAttachmentSample.invoice, true), + AttachmentUiModelMapper2.toUiModel(MessageAttachmentSample.document, true) + ) + ) + ) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/EffectsStateModificationTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/EffectsStateModificationTest.kt new file mode 100644 index 0000000000..c2004dd389 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/EffectsStateModificationTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.modifications + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.BottomSheetEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.CompletionEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ConfirmationsEffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.ContentEffectsStateModifications +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.EffectsStateModification +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.LoadingError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.RecoverableError +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.effects.UnrecoverableError +import ch.protonmail.android.mailmessage.domain.model.Recipient +import io.mockk.mockk +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class EffectsStateModificationTest( + @Suppress("unused") private val testName: String, + private val initialState: ComposerState.Effects, + private val modification: EffectsStateModification, + private val expectedState: ComposerState.Effects +) { + + @Test + fun `should apply the modification`() { + val updatedState = modification.apply(initialState) + assertEquals(expectedState, updatedState) + } + + companion object { + + private val initialState = ComposerState.Effects.initial() + private val externalRecipients = listOf(mockk()) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "shows invalid sender error", + initialState, + UnrecoverableError.InvalidSenderAddress, + initialState.copy(exitError = Effect.of(TextUiModel(R.string.composer_error_invalid_sender))) + ), + arrayOf( + "shows draft content loading error", + initialState, + LoadingError.DraftContent, + initialState.copy(error = Effect.of(TextUiModel(R.string.composer_error_loading_draft))) + ), + arrayOf( + "shows parent message loading error", + initialState, + UnrecoverableError.ParentMessageMetadata, + initialState.copy(exitError = Effect.of(TextUiModel(R.string.composer_error_loading_parent_message))) + ), + arrayOf( + "shows free user sender change error (paid feature)", + initialState, + RecoverableError.SenderChange.FreeUser, + initialState.copy( + premiumFeatureMessage = Effect.of(TextUiModel(R.string.composer_change_sender_paid_feature)) + ) + ), + arrayOf( + "shows failed getting permissions on sender change error", + initialState, + RecoverableError.SenderChange.UnknownPermissions, + initialState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_change_sender_failed_getting_subscription)) + ) + ), + arrayOf( + "shows attachments not found error", + initialState, + RecoverableError.AttachmentsStore(StoreDraftWithAttachmentError.AttachmentsMissing), + initialState.copy(error = Effect.of(TextUiModel.TextRes(R.string.composer_attachment_not_found))) + ), + arrayOf( + "show file size exceeded error", + initialState, + RecoverableError.AttachmentsStore(StoreDraftWithAttachmentError.FileSizeExceedsLimit), + initialState.copy(attachmentsFileSizeExceeded = Effect.of(Unit)) + ), + arrayOf( + "shows attachments re-encryption failed error", + initialState, + RecoverableError.ReEncryptAttachment, + initialState.copy( + attachmentsReEncryptionFailed = Effect.of( + TextUiModel(R.string.composer_attachment_reencryption_failed_message) + ) + ) + ), + arrayOf( + "shows expiration error", + initialState, + RecoverableError.Expiration, + initialState.copy( + error = Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)), + changeBottomSheetVisibility = Effect.of(false) + ) + ), + arrayOf( + "shows sending failed error", + initialState, + RecoverableError.SendingFailed("Test error"), + initialState.copy(sendingErrorEffect = Effect.of(TextUiModel.Text("Test error"))) + ), + arrayOf( + "shows image picker", + initialState, + ContentEffectsStateModifications.OnAddAttachmentRequested, + initialState.copy(openImagePicker = Effect.of(Unit)) + ), + arrayOf( + "handles draft content ready with valid sender", + initialState, + ContentEffectsStateModifications.DraftContentReady( + ValidateSenderAddress.ValidationResult.Valid(SenderEmail("test@example.com")), + isDataRefresh = true, + forceBodyFocus = true + ), + initialState.copy(focusTextBody = Effect.of(Unit)) + ), + arrayOf( + "handles draft content ready with paid address error", + initialState, + ContentEffectsStateModifications.DraftContentReady( + ValidateSenderAddress.ValidationResult.Invalid( + validAddress = SenderEmail("test@proton.me"), + invalid = SenderEmail("a@b.c"), + reason = ValidateSenderAddress.ValidationError.PaidAddress + ), + isDataRefresh = true, + forceBodyFocus = false + ), + initialState.copy( + senderChangedNotice = Effect.of( + TextUiModel(R.string.composer_sender_changed_pm_address_is_a_paid_feature) + ) + ) + ), + arrayOf( + "handles draft content ready with disabled address error", + initialState, + ContentEffectsStateModifications.DraftContentReady( + ValidateSenderAddress.ValidationResult.Invalid( + validAddress = SenderEmail("test@proton.me"), + invalid = SenderEmail("a@b.c"), + reason = ValidateSenderAddress.ValidationError.DisabledAddress + ), + isDataRefresh = true, + forceBodyFocus = false + ), + initialState.copy( + senderChangedNotice = Effect.of( + TextUiModel(R.string.composer_sender_changed_original_address_disabled) + ) + ) + ), + arrayOf( + "closes composer with saved draft", + initialState, + CompletionEffectsStateModification.CloseComposer(hasSavedDraft = true), + initialState.copy(closeComposerWithDraftSaved = Effect.of(Unit)) + ), + arrayOf( + "closes composer without saved draft", + initialState, + CompletionEffectsStateModification.CloseComposer(hasSavedDraft = false), + initialState.copy(closeComposer = Effect.of(Unit)) + ), + arrayOf( + "sends message and exit", + initialState, + CompletionEffectsStateModification.SendMessage.SendAndExit, + initialState.copy(closeComposerWithMessageSending = Effect.of(Unit)) + ), + arrayOf( + "sends message and exit offline", + initialState, + CompletionEffectsStateModification.SendMessage.SendAndExitOffline, + initialState.copy(closeComposerWithMessageSendingOffline = Effect.of(Unit)) + ), + arrayOf( + "shows bottom sheet", + initialState, + BottomSheetEffectsStateModification.ShowBottomSheet, + initialState.copy(changeBottomSheetVisibility = Effect.of(true)) + ), + arrayOf( + "hides bottom sheet", + initialState, + BottomSheetEffectsStateModification.HideBottomSheet, + initialState.copy(changeBottomSheetVisibility = Effect.of(false)) + ), + arrayOf( + "requests no subject confirmation", + initialState, + ConfirmationsEffectsStateModification.SendNoSubjectConfirmationRequested, + initialState.copy(confirmSendingWithoutSubject = Effect.of(Unit)) + ), + arrayOf( + "cancels no subject confirmation", + initialState, + ConfirmationsEffectsStateModification.CancelSendNoSubject, + initialState.copy( + changeFocusToField = Effect.of(FocusedFieldType.SUBJECT), + confirmSendingWithoutSubject = Effect.empty() + ) + ), + arrayOf( + "shows external expiring recipients confirmation", + initialState, + ConfirmationsEffectsStateModification.ShowExternalExpiringRecipients(externalRecipients), + initialState.copy(confirmSendExpiringMessage = Effect.of(externalRecipients)) + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/MainStateModificationTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/MainStateModificationTest.kt new file mode 100644 index 0000000000..348d93f911 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/mapper/modifications/MainStateModificationTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.mapper.modifications + +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.reducer.modifications.MainStateModification +import ch.protonmail.android.mailmessage.domain.model.MessageId +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +internal class MainStateModificationTest( + @Suppress("unused") private val testName: String, + private val initialState: ComposerState.Main, + private val modification: MainStateModification, + private val expectedState: ComposerState.Main +) { + + @Test + fun `should apply the modification`() { + val updatedState = modification.apply(initialState) + assertEquals(expectedState, updatedState) + } + + companion object { + + private val initialState = ComposerState.Main.initial(draftId = MessageId("1234")) + private val quotedHtmlContent = mockk() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = listOf( + arrayOf( + "set sender and quoted content with no restricted height from initial state", + initialState, + MainStateModification.OnDraftReady( + sender = "test@example.com", + quotedHtmlContent = quotedHtmlContent, + shouldRestrictWebViewHeight = false + ), + initialState.copy( + senderUiModel = SenderUiModel("test@example.com"), + quotedHtmlContent = quotedHtmlContent + ) + ), + arrayOf( + "set sender and quoted content with restricted height from initial state", + initialState, + MainStateModification.OnDraftReady( + sender = "test@example.com", + quotedHtmlContent = quotedHtmlContent, + shouldRestrictWebViewHeight = true + ), + initialState.copy( + senderUiModel = SenderUiModel("test@example.com"), + quotedHtmlContent = quotedHtmlContent, + shouldRestrictWebViewHeight = true + ) + ), + arrayOf( + "set sender without quoted content", + initialState, + MainStateModification.OnDraftReady( + sender = "another@example.com", + quotedHtmlContent = null, + shouldRestrictWebViewHeight = false + ), + initialState.copy( + senderUiModel = SenderUiModel("another@example.com") + ) + ), + arrayOf( + "update loading type (save)", + initialState, + MainStateModification.UpdateLoading(ComposerState.LoadingType.Initial), + initialState.copy(loadingType = ComposerState.LoadingType.Initial) + ), + arrayOf( + "update loading type (initial)", + initialState, + MainStateModification.UpdateLoading(ComposerState.LoadingType.Save), + initialState.copy(loadingType = ComposerState.LoadingType.Save) + ), + arrayOf( + "update loading type (save)", + initialState, + MainStateModification.UpdateLoading(ComposerState.LoadingType.None), + initialState.copy(loadingType = ComposerState.LoadingType.None) + ), + arrayOf( + "update sender from initial state", + initialState, + MainStateModification.UpdateSender(SenderEmail("newSender@example.com")), + initialState.copy(senderUiModel = SenderUiModel("newSender@example.com")) + ), + arrayOf( + "update senders list", + initialState, + MainStateModification.SendersListReady( + listOf( + SenderUiModel("sender1@example.com"), + SenderUiModel("sender2@example.com") + ) + ), + initialState.copy( + senderAddresses = listOf( + SenderUiModel("sender1@example.com"), + SenderUiModel("sender2@example.com") + ).toImmutableList() + ) + ), + arrayOf( + "update submittable to true", + initialState, + MainStateModification.UpdateSubmittable(true), + initialState.copy(isSubmittable = true) + ), + arrayOf( + "update submittable to false", + initialState.copy(isSubmittable = true), + MainStateModification.UpdateSubmittable(false), + initialState.copy(isSubmittable = false) + ), + arrayOf( + "remove quoted HTML text", + initialState.copy(quotedHtmlContent = quotedHtmlContent), + MainStateModification.RemoveHtmlQuotedText, + initialState + ) + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManagerTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManagerTest.kt new file mode 100644 index 0000000000..498ccf855c --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/model/RecipientsStateManagerTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.model + +import ch.protonmail.android.mailmessage.domain.model.Participant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class RecipientsStateManagerTest { + + @Test + fun `initial state should be empty`() { + // Given + val recipientsStateManager = RecipientsStateManager() + + // When + val actual = recipientsStateManager.recipients.value + + // Then + assertEquals(RecipientsState.Empty, actual) + } + + @Test + fun `should return invalid recipients when empty()`() { + // Given + val recipientsStateManager = RecipientsStateManager() + + // Then + assertFalse(recipientsStateManager.hasValidRecipients()) + } + + @Test + fun `should return valid when not empty with valid recipients()`() { + // Given + val recipientsStateManager = RecipientsStateManager() + val rawToList = listOf("a@bb.cc", "cc@bb.aa") + val rawCcList = listOf("one@two.three", "two@three.four") + val rawBccList = listOf("bcc@cc.to", "to@cc.bcc") + + // When + recipientsStateManager.setFromRawRecipients( + toRecipients = rawToList, + ccRecipients = rawCcList, + bccRecipients = rawBccList + ) + + // Then + assertTrue(recipientsStateManager.hasValidRecipients()) + } + + @Test + fun `should set recipients when updated from raw values`() { + // Given + val recipientsStateManager = RecipientsStateManager() + + val rawToList = listOf("a@bb.cc", "@@") + val expectedToList = listOf(RecipientUiModel.Valid("a@bb.cc"), RecipientUiModel.Invalid("@@")) + + val rawCcList = listOf("one@two.three", "123", "two@three.four", "three@four.five") + val expectedCcList = listOf( + RecipientUiModel.Valid("one@two.three"), + RecipientUiModel.Invalid("123"), + RecipientUiModel.Valid("two@three.four"), + RecipientUiModel.Valid("three@four.five") + ) + + val rawBccList = listOf("aaa", "test@example.com", "com@example.test") + val expectedBccList = listOf( + RecipientUiModel.Invalid("aaa"), + RecipientUiModel.Valid("test@example.com"), + RecipientUiModel.Valid("com@example.test") + ) + + // When + recipientsStateManager.setFromRawRecipients( + toRecipients = rawToList, + ccRecipients = rawCcList, + bccRecipients = rawBccList + ) + + val actualRecipients = recipientsStateManager.recipients.value + + // Then + assertEquals(expectedToList, actualRecipients.toRecipients) + assertEquals(expectedCcList, actualRecipients.ccRecipients) + assertEquals(expectedBccList, actualRecipients.bccRecipients) + } + + @Test + fun `should set recipients when updated from participants`() { + // Given + val recipientsStateManager = RecipientsStateManager() + + val rawToList = listOf(generateParticipant("a@bb.cc"), generateParticipant("@@")) + val expectedToList = listOf(RecipientUiModel.Valid("a@bb.cc"), RecipientUiModel.Invalid("@@")) + + val rawCcList = listOf( + generateParticipant("one@two.three"), + generateParticipant("123"), + generateParticipant("two@three.four"), + generateParticipant("three@four.five") + ) + val expectedCcList = listOf( + RecipientUiModel.Valid("one@two.three"), + RecipientUiModel.Invalid("123"), + RecipientUiModel.Valid("two@three.four"), + RecipientUiModel.Valid("three@four.five") + ) + + val rawBccList = listOf( + generateParticipant("aaa"), + generateParticipant("test@example.com"), + generateParticipant("com@example.test") + ) + val expectedBccList = listOf( + RecipientUiModel.Invalid("aaa"), + RecipientUiModel.Valid("test@example.com"), + RecipientUiModel.Valid("com@example.test") + ) + + // When + recipientsStateManager.setFromParticipants( + toRecipients = rawToList, + ccRecipients = rawCcList, + bccRecipients = rawBccList + ) + + val actualRecipients = recipientsStateManager.recipients.value + + // Then + assertEquals(expectedToList, actualRecipients.toRecipients) + assertEquals(expectedCcList, actualRecipients.ccRecipients) + assertEquals(expectedBccList, actualRecipients.bccRecipients) + } + + @Test + fun `should update the recipients depending on the recipient type`() { + // Given + val recipientsStateManager = RecipientsStateManager() + val toRecipients = listOf( + RecipientUiModel.Valid("valid@toaddress.com"), + RecipientUiModel.Invalid("invalidtoaddress.com") + ) + + val ccRecipients = listOf( + RecipientUiModel.Valid("valid@toaddress.com"), + RecipientUiModel.Invalid("invalidcctoaddress.com") + ) + + val bccRecipients = listOf( + RecipientUiModel.Valid("valid@bccaddress.com"), + RecipientUiModel.Invalid("invalidbccaddress.com") + ) + + // When + recipientsStateManager.updateRecipients(toRecipients, ContactSuggestionsField.TO) + recipientsStateManager.updateRecipients(ccRecipients, ContactSuggestionsField.CC) + recipientsStateManager.updateRecipients(bccRecipients, ContactSuggestionsField.BCC) + + val actualRecipients = recipientsStateManager.recipients.value + + // Then + assertEquals(toRecipients, actualRecipients.toRecipients) + assertEquals(ccRecipients, actualRecipients.ccRecipients) + assertEquals(bccRecipients, actualRecipients.bccRecipients) + } + + private fun generateParticipant(address: String) = Participant(name = address.reversed(), address = address) +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducerTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducerTest.kt new file mode 100644 index 0000000000..c726e23006 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/ComposerReducerTest.kt @@ -0,0 +1,1130 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer + +import java.util.Random +import java.util.UUID +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction.RecipientsBccChanged +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction.RecipientsCcChanged +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction.RecipientsToChanged +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction.SenderChanged +import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerEvent +import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields +import ch.protonmail.android.mailcomposer.presentation.model.ComposerOperation +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.DraftUiModel +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel.Invalid +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel.Valid +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import ch.protonmail.android.mailmessage.presentation.sample.AttachmentUiModelSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +@RunWith(Parameterized::class) +class ComposerReducerTest( + private val testName: String, + private val testTransition: TestTransition +) { + + private val attachmentUiModelMapper = AttachmentUiModelMapper() + private val shouldRestrictWebViewHeight = mockk { + every { this@mockk.invoke(null) } returns false + } + private val composerReducer = ComposerReducer(attachmentUiModelMapper, shouldRestrictWebViewHeight) + + @Test + fun `Test composer transition states`() = runTest { + with(testTransition) { + val actualState = composerReducer.newStateFrom(currentState, operation) + + assertEquals(expectedState, actualState, testName) + } + } + + companion object { + + private val messageId = MessageId(UUID.randomUUID().toString()) + private val addresses = listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) + + private val draftFields = DraftFields( + SenderEmail("author@proton.me"), + Subject("Here is the matter"), + DraftBody("Decrypted body of this draft"), + RecipientsTo(listOf(Recipient("you@proton.ch", "Name"))), + RecipientsCc(emptyList()), + RecipientsBcc(emptyList()), + null + ) + + private val draftFieldsWithoutRecipients = DraftFields( + SenderEmail("author@proton.me"), + Subject("Here is the matter"), + DraftBody("Decrypted body of this draft"), + RecipientsTo(emptyList()), + RecipientsCc(emptyList()), + RecipientsBcc(emptyList()), + null + ) + + private val draftUiModel = DraftUiModel(draftFields, null) + + private val draftUiModelWithoutRecipients = DraftUiModel(draftFieldsWithoutRecipients, null) + + private val EmptyToSubmittableToField = with("a@b.c") { + TestTransition( + name = "Should generate submittable state when adding a new valid email address in the to field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsToChanged(listOf(Valid(this))), + expectedState = aSubmittableState(messageId, to = listOf(Valid(this))) + ) + } + + private val EmptyToNotSubmittableToField = with(UUID.randomUUID().toString()) { + TestTransition( + name = "Should generate not submittable error state when adding invalid email address in the to field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsToChanged(listOf(Invalid(this))), + expectedState = aNotSubmittableState( + messageId, + to = listOf(Invalid(this)), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + ) + ) + } + + private val SubmittableToNotSubmittableEmptyToField = with("a@b.c") { + TestTransition( + name = "Should generate not-submittable state when removing all valid email addresses from TO field", + currentState = ComposerDraftState.initial( + messageId, + to = listOf(Valid(this)), + isSubmittable = true + ), + operation = RecipientsToChanged(emptyList()), + expectedState = aNotSubmittableState(messageId, to = emptyList(), error = Effect.empty()) + ) + } + + private val EmptyToSubmittableCcField = with("a@b.c") { + TestTransition( + name = "Should generate submittable state when adding a new valid email address in the cc field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsCcChanged(listOf(Valid(this))), + expectedState = aSubmittableState(messageId, cc = listOf(Valid(this))) + ) + } + + private val EmptyToNotSubmittableCcField = with(UUID.randomUUID().toString()) { + TestTransition( + name = "Should generate not submittable error state when adding invalid email address in the cc field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsCcChanged(listOf(Invalid(this))), + expectedState = aNotSubmittableState( + messageId, + cc = listOf(Invalid(this)), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + ) + ) + } + + private val SubmittableToNotSubmittableEmptyCcField = with("a@b.c") { + TestTransition( + name = "Should generate not-submittable state when removing all valid email addresses from CC field", + currentState = ComposerDraftState.initial( + messageId, + cc = listOf(Valid(this)), + isSubmittable = true + ), + operation = RecipientsCcChanged(emptyList()), + expectedState = aNotSubmittableState(messageId, cc = emptyList(), error = Effect.empty()) + ) + } + + private val EmptyToSubmittableBccField = with("a@b.c") { + TestTransition( + name = "Should generate submittable state when adding a new valid email address in the bcc field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsBccChanged(listOf(Valid(this))), + expectedState = aSubmittableState(messageId, bcc = listOf(Valid(this))) + ) + } + + private val EmptyToNotSubmittableBccField = with(UUID.randomUUID().toString()) { + TestTransition( + name = "Should generate not submittable error state when adding invalid email address in the bcc field", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsBccChanged(listOf(Invalid(this))), + expectedState = aNotSubmittableState( + messageId, + bcc = listOf(Invalid(this)), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + ) + ) + } + + private val SubmittableToNotSubmittableEmptyBccField = with("a@b.c") { + TestTransition( + name = "Should generate not-submittable state when removing all valid email addresses from BCC field", + currentState = ComposerDraftState.initial( + messageId, + bcc = listOf(Valid(this)), + isSubmittable = true + ), + operation = RecipientsBccChanged(emptyList()), + expectedState = aNotSubmittableState(messageId, bcc = emptyList(), error = Effect.empty()) + ) + } + + private val NotSubmittableToWithoutErrorToField = with("a@b.c") { + val invalidEmail = UUID.randomUUID().toString() + TestTransition( + name = "Should generate not submittable non error state when adding valid email to current error", + currentState = aNotSubmittableState(messageId, to = listOf(Invalid(invalidEmail))), + operation = RecipientsToChanged(listOf(Invalid(invalidEmail), Valid(this))), + expectedState = aNotSubmittableState( + draftId = messageId, + to = listOf(Invalid(invalidEmail), Valid(this)), + error = Effect.empty() + ) + ) + } + + private val NotSubmittableToWithErrorToField = with("a@b.c") { + val invalidEmail = UUID.randomUUID().toString() + TestTransition( + name = "Should generate not submittable error state when adding invalid followed by invalid address", + currentState = aNotSubmittableState(messageId, to = listOf(Invalid(invalidEmail))), + operation = RecipientsToChanged(listOf(Invalid(invalidEmail), Invalid(this))), + expectedState = aNotSubmittableState( + draftId = messageId, + to = listOf(Invalid(invalidEmail), Invalid(this)), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + ) + ) + } + + private val NotSubmittableWithoutErrorWhenRemoving = with("a@b.c") { + val invalidEmail = UUID.randomUUID().toString() + TestTransition( + name = "Should generate not submittable state without error when removing invalid address", + currentState = aNotSubmittableState(messageId, to = listOf(Invalid(invalidEmail), Invalid(this))), + operation = RecipientsToChanged(listOf(Invalid(invalidEmail))), + expectedState = aNotSubmittableState( + draftId = messageId, + to = listOf(Invalid(invalidEmail)), + error = Effect.empty() + ) + ) + } + + private val EmptyToUpgradePlan = TestTransition( + name = "Should generate a state showing 'upgrade plan' message when free user tries to change sender", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ErrorFreeUserCannotChangeSender, + expectedState = aNotSubmittableState( + draftId = messageId, + premiumFeatureMessage = Effect.of(TextUiModel(R.string.composer_change_sender_paid_feature)), + error = Effect.empty() + ) + ) + + private val EmptyToSenderAddressesList = TestTransition( + name = "Should generate a state showing change sender bottom sheet when paid tries to change sender", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.SenderAddressesReceived(addresses.map { SenderUiModel(it.email) }), + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.empty(), + senderAddresses = addresses.map { SenderUiModel(it.email) }, + changeSenderBottomSheetVisibility = Effect.of(true) + ) + ) + + private val EmptyToErrorWhenUserPlanUnknown = TestTransition( + name = "Should generate an error state when failing to determine if user can change sender", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ErrorVerifyingPermissionsToChangeSender, + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.of(TextUiModel(R.string.composer_error_change_sender_failed_getting_subscription)) + ) + ) + + private val EmptyToUpdatedSender = with(SenderUiModel("updated-sender@proton.ch")) { + TestTransition( + name = "Should update the state with the new sender and close bottom sheet when address changes", + currentState = ComposerDraftState.initial(messageId), + operation = SenderChanged(this), + expectedState = aNotSubmittableState( + draftId = messageId, + sender = this, + error = Effect.empty(), + changeSenderBottomSheetVisibility = Effect.of(false) + ) + ) + } + + private val EmptyToChangeSubjectError = TestTransition( + name = "Should update the state showing an error when error storing draft subject", + currentState = aNotSubmittableState(draftId = messageId), + operation = ComposerEvent.ErrorStoringDraftSubject, + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.of(TextUiModel(R.string.composer_error_store_draft_subject)) + ) + ) + + private val DefaultSenderToChangeSenderFailed = TestTransition( + name = "Should update the state showing an error and preserving the previous sender address", + currentState = aNotSubmittableState( + draftId = messageId, + sender = SenderUiModel("default@pm.me") + ), + operation = ComposerEvent.ErrorStoringDraftSenderAddress, + expectedState = aNotSubmittableState( + draftId = messageId, + sender = SenderUiModel("default@pm.me"), + error = Effect.of(TextUiModel(R.string.composer_error_store_draft_sender_address)), + changeSenderBottomSheetVisibility = Effect.of(false) + ) + ) + + private val DuplicateToToNotDuplicateWithError = with(aMultipleRandomRange().map { "a@b.c" }) { + TestTransition( + name = "Should remove duplicate TO recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsToChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + to = listOf(Valid(this.first())), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val DuplicateCcToNotDuplicateWithError = with(aMultipleRandomRange().map { "a@b.c" }) { + TestTransition( + name = "Should remove duplicate CC recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsCcChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + cc = listOf(Valid(this.first())), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val DuplicateBccToNotDuplicateWithError = with(aMultipleRandomRange().map { "a@b.c" }) { + TestTransition( + name = "Should remove duplicate BCC recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsBccChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + bcc = listOf(Valid(this.first())), + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val ManyDuplicatesToToNotDuplicateWithError = with( + aMultipleRandomRange().map { "a@b.c" } + aMultipleRandomRange().map { "d@e.f" } + ) { + val expected = listOf(Valid("a@b.c"), Valid("d@e.f")) + TestTransition( + name = "Should remove multiple duplicate To recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsToChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + to = expected, + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val ManyDuplicatesCcToNotDuplicateWithError = with( + aMultipleRandomRange().map { "a@b.c" } + aMultipleRandomRange().map { "d@e.f" } + ) { + val expected = listOf(Valid("a@b.c"), Valid("d@e.f")) + TestTransition( + name = "Should remove multiple duplicate CC recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsCcChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + cc = expected, + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val ManyDuplicatesBccToNotDuplicateWithError = with( + aMultipleRandomRange().map { "a@b.c" } + aMultipleRandomRange().map { "d@e.f" } + ) { + val expected = listOf(Valid("a@b.c"), Valid("d@e.f")) + TestTransition( + name = "Should remove multiple duplicate BCC recipients and contain error if there are", + currentState = ComposerDraftState.initial(messageId), + operation = RecipientsBccChanged(this.map { Valid(it) }), + expectedState = aSubmittableState( + draftId = messageId, + bcc = expected, + recipientValidationError = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + ) + ) + } + + private val EmptyToUpdatedDraftBody = with(DraftBody("Updated draft body")) { + TestTransition( + name = "Should update the state with the new draft body when it changes", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.DraftBodyChanged(this), + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.empty(), + draftBody = this.value + ) + ) + } + + private val EmptyToUpdatedSubject = with(Subject("This is a new subject")) { + TestTransition( + name = "Should update the state with the new subject when it changes", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.SubjectChanged(this), + expectedState = aNotSubmittableState( + draftId = messageId, + subject = this, + error = Effect.empty() + ) + ) + } + + private val MultilinedSubject = with(Subject("This\n\n is a\r multiline\n subject\n\r")) { + arrayOf( + TestTransition( + name = "Should strip new line characters from the subject when saving it", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.SubjectChanged(this), + expectedState = aNotSubmittableState( + draftId = messageId, + subject = Subject("This is a multiline subject "), + error = Effect.empty() + ) + ), + TestTransition( + name = "Should strip new line characters from the subject when updating an existing one", + currentState = aNotSubmittableState( + draftId = messageId, + subject = Subject("Some subject"), + error = Effect.empty() + ), + operation = ComposerAction.SubjectChanged(this), + expectedState = aNotSubmittableState( + draftId = messageId, + subject = Subject("This is a multiline subject "), + error = Effect.empty() + ) + ) + ) + } + + private val EmptyToCloseComposer = TestTransition( + name = "Should close the composer", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.OnCloseComposer, + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.empty(), + closeComposer = Effect.of(Unit), + closeComposerWithDraftSaved = Effect.empty() + ) + ) + + private val EmptyToCloseComposerWithDraftSaved = TestTransition( + name = "Should close the composer notifying draft saved", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnCloseWithDraftSaved, + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.empty(), + closeComposer = Effect.empty(), + closeComposerWithDraftSaved = Effect.of(Unit) + ) + ) + + private val SubmittableToSendMessage = + TestTransition( + name = "Should update submittable state with message sending after OnSendMessage action", + currentState = aSubmittableState(messageId), + operation = ComposerAction.OnSendMessage, + expectedState = aSubmittableState( + messageId, + closeComposerWithMessageSending = Effect.of(Unit) + ) + ) + + private val SubmittableToOnSendMessageOffline = + TestTransition( + name = "Should update submittable state with message sending after OnSendMessageOffline action", + currentState = aSubmittableState(messageId), + operation = ComposerEvent.OnSendMessageOffline, + expectedState = aSubmittableState( + messageId, + closeComposerWithMessageSendingOffline = Effect.of(Unit) + ) + ) + + private val ContactsSuggestionExpandedToDismissed = + TestTransition( + name = "Should update state with dismissing suggestions after ContactSuggestionsDismissed action", + currentState = ComposerDraftState.initial(messageId).copy( + areContactSuggestionsExpanded = mapOf(ContactSuggestionsField.BCC to true) + ), + operation = ComposerAction.ContactSuggestionsDismissed(ContactSuggestionsField.BCC), + expectedState = aNotSubmittableState( + messageId, + error = Effect.empty(), + areContactSuggestionsExpanded = mapOf(ContactSuggestionsField.BCC to false) + ) + ) + + private val EmptyToLoadingWithOpenExistingDraft = TestTransition( + name = "Should set state to loading when open of existing draft was requested", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OpenExistingDraft(messageId), + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.empty(), + isLoading = true + ) + ) + + @Suppress("VariableMaxLength") + private val LoadingToFieldsWhenReceivedDraftDataEmptyRecipients = TestTransition( + name = "Should stop loading and set the received draft data as composer fields when draft data received, " + + "empty recipients", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.PrefillDraftDataReceived( + draftUiModelWithoutRecipients, + isDataRefreshed = true, + isBlockedSendingFromPmAddress = false, + isBlockedSendingFromDisabledAddress = false + ), + expectedState = aNotSubmittableState( + draftId = messageId, + sender = SenderUiModel(draftFieldsWithoutRecipients.sender.value), + to = draftFieldsWithoutRecipients.recipientsTo.value.map { Valid(it.address) }, + cc = draftFieldsWithoutRecipients.recipientsCc.value.map { Valid(it.address) }, + bcc = draftFieldsWithoutRecipients.recipientsBcc.value.map { Valid(it.address) }, + subject = draftFieldsWithoutRecipients.subject, + draftBody = draftFieldsWithoutRecipients.body.value, + error = Effect.empty(), + isLoading = false + ) + ) + + @Suppress("VariableMaxLength") + private val LoadingToFieldsWhenReceivedDraftDataValidRecipients = TestTransition( + name = "Should stop loading and set the received draft data as composer fields when draft data received, " + + "valid recipients", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.PrefillDraftDataReceived( + draftUiModel, + isDataRefreshed = true, + isBlockedSendingFromPmAddress = false, + isBlockedSendingFromDisabledAddress = false + ), + expectedState = aSubmittableState( + draftId = messageId, + sender = SenderUiModel(draftFieldsWithoutRecipients.sender.value), + to = draftFields.recipientsTo.value.map { Valid(it.address) }, + cc = draftFields.recipientsCc.value.map { Valid(it.address) }, + bcc = draftFields.recipientsBcc.value.map { Valid(it.address) }, + subject = draftFieldsWithoutRecipients.subject, + draftBody = draftFieldsWithoutRecipients.body.value, + error = Effect.empty() + ) + ) + + @Suppress("VariableMaxLength") + private val LoadingToFieldsWhenReceivedDraftDataFromLocal = TestTransition( + name = "Should stop loading and set the received draft data as composer fields when draft data received, " + + "valid recipients", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.PrefillDraftDataReceived( + draftUiModel, + isDataRefreshed = false, + isBlockedSendingFromPmAddress = false, + isBlockedSendingFromDisabledAddress = false + ), + expectedState = aSubmittableState( + draftId = messageId, + sender = SenderUiModel(draftFieldsWithoutRecipients.sender.value), + to = draftFields.recipientsTo.value.map { Valid(it.address) }, + cc = draftFields.recipientsCc.value.map { Valid(it.address) }, + bcc = draftFields.recipientsBcc.value.map { Valid(it.address) }, + subject = draftFieldsWithoutRecipients.subject, + draftBody = draftFieldsWithoutRecipients.body.value, + warning = Effect.of(TextUiModel(R.string.composer_warning_local_data_shown)) + ) + ) + + @Suppress("VariableMaxLength") + private val LoadingToFieldsWhenReceivedDraftDataFromViaShare = TestTransition( + name = "Should stop loading and set the received draft data as composer fields when draft data received, " + + " via share", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.PrefillDataReceivedViaShare(draftUiModel), + expectedState = aSubmittableState( + draftId = messageId, + sender = SenderUiModel(draftFieldsWithoutRecipients.sender.value), + to = draftFields.recipientsTo.value.map { Valid(it.address) }, + cc = draftFields.recipientsCc.value.map { Valid(it.address) }, + bcc = draftFields.recipientsBcc.value.map { Valid(it.address) }, + subject = draftFieldsWithoutRecipients.subject, + draftBody = draftFieldsWithoutRecipients.body.value, + warning = Effect.empty() + ) + ) + + @Suppress("VariableMaxLength") + private val LoadingToSendingNoticeWhenReceivedDraftDataWithInvalidSender = TestTransition( + name = "Should stop loading and show the sending notice when prefilled address in invalid", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.PrefillDraftDataReceived( + draftUiModelWithoutRecipients, + isDataRefreshed = true, + isBlockedSendingFromPmAddress = true, + isBlockedSendingFromDisabledAddress = false + ), + expectedState = aNotSubmittableState( + draftId = messageId, + sender = SenderUiModel(draftFieldsWithoutRecipients.sender.value), + to = draftFieldsWithoutRecipients.recipientsTo.value.map { Valid(it.address) }, + cc = draftFieldsWithoutRecipients.recipientsCc.value.map { Valid(it.address) }, + bcc = draftFieldsWithoutRecipients.recipientsBcc.value.map { Valid(it.address) }, + subject = draftFieldsWithoutRecipients.subject, + draftBody = draftFieldsWithoutRecipients.body.value, + error = Effect.empty(), + isLoading = false, + senderChangedNotice = Effect.of( + TextUiModel(R.string.composer_sender_changed_pm_address_is_a_paid_feature) + ) + ) + ) + + @Suppress("VariableMaxLength") + private val EmptyToStateWhenReplaceDraftBody = TestTransition( + name = "Should update the state with new DraftBody Effect when ReplaceDraftBody was emitted", + currentState = aNotSubmittableState(draftId = messageId), + operation = ComposerEvent.ReplaceDraftBody(draftFieldsWithoutRecipients.body), + expectedState = aNotSubmittableState( + draftId = messageId, + replaceDraftBody = Effect.of(TextUiModel(draftFieldsWithoutRecipients.body.value)) + ) + ) + + private val LoadingToErrorWhenErrorLoadingDraftData = TestTransition( + name = "Should stop loading and display error when failing to receive draft data", + currentState = ComposerDraftState.initial(messageId).copy(isLoading = true), + operation = ComposerEvent.ErrorLoadingDraftData, + expectedState = aNotSubmittableState( + draftId = messageId, + error = Effect.of(TextUiModel(R.string.composer_error_loading_draft)), + isLoading = false + ) + ) + + private val EmptyToBottomSheetOpened = TestTransition( + name = "Should open the file picker when add attachments action is chosen", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.OnAddAttachments, + expectedState = ComposerDraftState.initial(messageId).copy( + openImagePicker = Effect.of(Unit) + ) + ) + + private val EmptyToAttachmentsUpdated = TestTransition( + name = "Should emit attachments when they are updated", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnAttachmentsUpdated(listOf(MessageAttachmentSample.invoice)), + expectedState = ComposerDraftState.initial(messageId).copy( + attachments = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = listOf(AttachmentUiModelSample.deletableInvoice) + ) + ) + ) + + private val EmptyToAttachmentFileExceeded = TestTransition( + name = "Should emit attachment exceeded file limit", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ErrorAttachmentsExceedSizeLimit, + expectedState = ComposerDraftState.initial(messageId).copy( + attachmentsFileSizeExceeded = Effect.of(Unit) + ) + ) + + private val EmptyToAttachmentReEncryptionFailed = TestTransition( + name = "Should emit attachment exceeded file limit", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ErrorAttachmentsReEncryption, + expectedState = ComposerDraftState.initial(messageId).copy( + attachmentsReEncryptionFailed = Effect.of(Unit) + ) + ) + + private val EmptyToOnSendingError = TestTransition( + name = "Should emit sending error", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnSendingError(TextUiModel.Text("SendingError")), + expectedState = ComposerDraftState.initial(messageId).copy( + sendingErrorEffect = Effect.of(TextUiModel.Text("SendingError")) + ) + ) + + private val EmptyToUpdateContactSuggestions = TestTransition( + name = "Should update state with contact suggestions on UpdateContactSuggestions event", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.UpdateContactSuggestions( + contactSuggestions = listOf( + ContactSuggestionUiModel.Contact("contact name", "IN", "contact email"), + ContactSuggestionUiModel.ContactGroup("contact group name", listOf("contact@emai.il"), "#FF0000") + ), + suggestionsField = ContactSuggestionsField.BCC + ), + expectedState = ComposerDraftState.initial(messageId).copy( + contactSuggestions = mapOf( + ContactSuggestionsField.BCC to listOf( + ContactSuggestionUiModel.Contact("contact name", "IN", "contact email"), + ContactSuggestionUiModel.ContactGroup( + "contact group name", + listOf("contact@emai.il"), + "#FF0000" + ) + ) + ), + areContactSuggestionsExpanded = mapOf(ContactSuggestionsField.BCC to true) + ) + ) + + private val EmptyToOnMessagePasswordUpdated = TestTransition( + name = "Should update state with info whether a message password is set", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnMessagePasswordUpdated( + MessagePassword( + UserIdTestData.userId, + messageId, + "password", + null + ) + ), + expectedState = ComposerDraftState.initial(messageId).copy( + isMessagePasswordSet = true + ) + ) + private val SubmittableToRequestConfirmEmptySubject = TestTransition( + name = "Should update state to request confirmation for sending without subject", + currentState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.empty() + ), + operation = ComposerEvent.ConfirmEmptySubject, + expectedState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.of(Unit) + ) + ) + + private val SubmittableToConfirmEmptySubject = TestTransition( + name = "Should update state to confirm sending without subject", + currentState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.of(Unit) + ), + operation = ComposerAction.ConfirmSendingWithoutSubject, + expectedState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.empty(), + closeComposerWithMessageSending = Effect.of(Unit) + ) + ) + + private val SubmittableToRejectEmptySubject = TestTransition( + name = "Should update state to reject sending without subject", + currentState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.of(Unit), + changeFocusToField = Effect.empty() + ), + operation = ComposerAction.RejectSendingWithoutSubject, + expectedState = aSubmittableState( + messageId, + subject = Subject(""), + confirmSendingWithoutSubject = Effect.empty(), + changeFocusToField = Effect.of(FocusedFieldType.SUBJECT) + ) + ) + + private val EmptyToSetExpirationTimeRequested = TestTransition( + name = "Should update state to open expiration time bottom sheet", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.OnSetExpirationTimeRequested, + expectedState = ComposerDraftState.initial(messageId).copy(changeBottomSheetVisibility = Effect.of(true)) + ) + + private val EmptyToExpirationTimeSet = TestTransition( + name = "Should update state to open expiration time bottom sheet", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.ExpirationTimeSet(duration = 1.days), + expectedState = ComposerDraftState.initial(messageId).copy(changeBottomSheetVisibility = Effect.of(false)) + ) + + private val EmptyToErrorSettingExpirationTime = TestTransition( + name = "Should update state to an error when setting expiration time failed", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ErrorSettingExpirationTime, + expectedState = ComposerDraftState.initial(messageId).copy( + error = Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)) + ) + ) + + private val EmptyToMessageExpirationTimeUpdated = TestTransition( + name = "Should update state with message expiration time", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnMessageExpirationTimeUpdated( + MessageExpirationTime(UserIdTestData.userId, messageId, 1.days) + ), + expectedState = ComposerDraftState.initial(messageId).copy(messageExpiresIn = 1.days) + ) + + @Suppress("VariableMaxLength") + private val EmptyToOnIsDeviceContactsSuggestionsEnabled = TestTransition( + name = "Should update state with a flag when feature flag is fetched", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnIsDeviceContactsSuggestionsEnabled( + true + ), + expectedState = ComposerDraftState.initial(messageId).copy( + isDeviceContactsSuggestionsEnabled = true + ) + ) + + private val EmptyToDeviceContactsPromptDenied = TestTransition( + name = "Should update state with a flag when contacts permission is denied from custom dialog", + currentState = ComposerDraftState.initial(messageId).copy( + isDeviceContactsSuggestionsPromptEnabled = true + ), + operation = ComposerAction.DeviceContactsPromptDenied, + expectedState = ComposerDraftState.initial(messageId).copy( + isDeviceContactsSuggestionsPromptEnabled = false + ) + ) + + @Suppress("VariableMaxLength") + private val EmptyToOnIsDeviceContactsSuggestionsPromptEnabled = TestTransition( + name = "Should update state with a flag when contacts permission dialog state is read from preferences", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.OnIsDeviceContactsSuggestionsPromptEnabled(true), + expectedState = ComposerDraftState.initial(messageId).copy( + isDeviceContactsSuggestionsPromptEnabled = true + ) + ) + + private val EmptyToConfirmSendExpiringMessage = TestTransition( + name = "Should update state with an effect when sending an expiring message to external recipients", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerEvent.ConfirmSendExpiringMessageToExternalRecipients( + listOf(RecipientSample.ExternalEncrypted) + ), + expectedState = ComposerDraftState.initial(messageId).copy( + confirmSendExpiringMessage = Effect.of(listOf(RecipientSample.ExternalEncrypted)) + ) + ) + + @Suppress("VariableMaxLength") + private val SubmittableToReplaceDraftBodyOnRespondnline = TestTransition( + name = "Should update state to replace draft body and remove quoted html when reply inline", + currentState = aSubmittableState( + messageId, + draftBody = "Existing draft body", + quotedHtmlBody = QuotedHtmlContent( + OriginalHtmlQuote("original html"), + StyledHtmlQuote("styled html") + ), + replaceDraftBody = Effect.empty() + ), + operation = ComposerEvent.RespondInlineContent("/noriginal html plain text"), + expectedState = aSubmittableState( + messageId, + draftBody = "Existing draft body", + quotedHtmlBody = null, + replaceDraftBody = Effect.of(TextUiModel("Existing draft body/noriginal html plain text")) + ) + ) + + private val EmptyToUnchangedOnRespondInlineAction = TestTransition( + name = "Should change nothing when respond inline *action* is reduced", + currentState = ComposerDraftState.initial(messageId), + operation = ComposerAction.RespondInlineRequested, + expectedState = ComposerDraftState.initial(messageId) + ) + + private val transitions = listOf( + EmptyToSubmittableToField, + EmptyToNotSubmittableToField, + SubmittableToNotSubmittableEmptyToField, + EmptyToSubmittableCcField, + EmptyToNotSubmittableCcField, + SubmittableToNotSubmittableEmptyCcField, + EmptyToSubmittableBccField, + EmptyToNotSubmittableBccField, + SubmittableToNotSubmittableEmptyBccField, + NotSubmittableToWithoutErrorToField, + NotSubmittableToWithErrorToField, + NotSubmittableWithoutErrorWhenRemoving, + EmptyToUpgradePlan, + EmptyToSenderAddressesList, + EmptyToErrorWhenUserPlanUnknown, + EmptyToUpdatedSender, + EmptyToChangeSubjectError, + DefaultSenderToChangeSenderFailed, + DuplicateToToNotDuplicateWithError, + DuplicateCcToNotDuplicateWithError, + DuplicateBccToNotDuplicateWithError, + ManyDuplicatesToToNotDuplicateWithError, + ManyDuplicatesCcToNotDuplicateWithError, + ManyDuplicatesBccToNotDuplicateWithError, + EmptyToUpdatedDraftBody, + EmptyToUpdatedSubject, + EmptyToCloseComposer, + EmptyToCloseComposerWithDraftSaved, + EmptyToStateWhenReplaceDraftBody, + SubmittableToSendMessage, + SubmittableToOnSendMessageOffline, + ContactsSuggestionExpandedToDismissed, + EmptyToLoadingWithOpenExistingDraft, + LoadingToFieldsWhenReceivedDraftDataEmptyRecipients, + LoadingToFieldsWhenReceivedDraftDataValidRecipients, + LoadingToFieldsWhenReceivedDraftDataFromLocal, + LoadingToFieldsWhenReceivedDraftDataFromViaShare, + LoadingToErrorWhenErrorLoadingDraftData, + LoadingToSendingNoticeWhenReceivedDraftDataWithInvalidSender, + EmptyToBottomSheetOpened, + EmptyToAttachmentsUpdated, + EmptyToAttachmentFileExceeded, + EmptyToAttachmentReEncryptionFailed, + EmptyToOnSendingError, + EmptyToUpdateContactSuggestions, + EmptyToOnMessagePasswordUpdated, + SubmittableToRequestConfirmEmptySubject, + SubmittableToConfirmEmptySubject, + SubmittableToRejectEmptySubject, + EmptyToSetExpirationTimeRequested, + EmptyToExpirationTimeSet, + EmptyToErrorSettingExpirationTime, + EmptyToMessageExpirationTimeUpdated, + EmptyToOnIsDeviceContactsSuggestionsEnabled, + EmptyToDeviceContactsPromptDenied, + EmptyToOnIsDeviceContactsSuggestionsPromptEnabled, + EmptyToConfirmSendExpiringMessage, + *MultilinedSubject + ) + + private fun aSubmittableState( + draftId: MessageId, + sender: SenderUiModel = SenderUiModel(""), + to: List = emptyList(), + cc: List = emptyList(), + bcc: List = emptyList(), + draftBody: String = "", + subject: Subject = Subject(""), + quotedHtmlBody: QuotedHtmlContent? = null, + recipientValidationError: Effect = Effect.empty(), + error: Effect = Effect.empty(), + closeComposerWithMessageSending: Effect = Effect.empty(), + closeComposerWithMessageSendingOffline: Effect = Effect.empty(), + confirmSendingWithoutSubject: Effect = Effect.empty(), + changeFocusToField: Effect = Effect.empty(), + attachmentsFileSizeExceeded: Effect = Effect.empty(), + attachmentReEncryptionFailed: Effect = Effect.empty(), + warning: Effect = Effect.empty(), + replaceDraftBody: Effect = Effect.empty() + ) = ComposerDraftState( + fields = ComposerFields( + draftId = draftId, + sender = sender, + to = to, + cc = cc, + bcc = bcc, + subject = subject.value, + body = draftBody, + quotedBody = quotedHtmlBody + ), + attachments = AttachmentGroupUiModel(attachments = emptyList()), + premiumFeatureMessage = Effect.empty(), + recipientValidationError = recipientValidationError, + error = error, + isSubmittable = true, + senderAddresses = emptyList(), + changeBottomSheetVisibility = Effect.empty(), + closeComposer = Effect.empty(), + closeComposerWithDraftSaved = Effect.empty(), + confirmSendingWithoutSubject = confirmSendingWithoutSubject, + changeFocusToField = changeFocusToField, + isLoading = false, + closeComposerWithMessageSending = closeComposerWithMessageSending, + closeComposerWithMessageSendingOffline = closeComposerWithMessageSendingOffline, + attachmentsFileSizeExceeded = attachmentsFileSizeExceeded, + attachmentsReEncryptionFailed = attachmentReEncryptionFailed, + warning = warning, + replaceDraftBody = replaceDraftBody, + isMessagePasswordSet = false, + messageExpiresIn = Duration.ZERO, + confirmSendExpiringMessage = Effect.empty(), + isDeviceContactsSuggestionsEnabled = false, + isDeviceContactsSuggestionsPromptEnabled = false, + openImagePicker = Effect.empty(), + shouldRestrictWebViewHeight = false + ) + + private fun aNotSubmittableState( + draftId: MessageId, + sender: SenderUiModel = SenderUiModel(""), + to: List = emptyList(), + cc: List = emptyList(), + bcc: List = emptyList(), + recipientValidationError: Effect = Effect.empty(), + error: Effect = Effect.empty(), + premiumFeatureMessage: Effect = Effect.empty(), + senderAddresses: List = emptyList(), + changeSenderBottomSheetVisibility: Effect = Effect.empty(), + draftBody: String = "", + subject: Subject = Subject(""), + closeComposer: Effect = Effect.empty(), + closeComposerWithDraftSaved: Effect = Effect.empty(), + isLoading: Boolean = false, + attachmentsFileSizeExceeded: Effect = Effect.empty(), + attachmentReEncryptionFailed: Effect = Effect.empty(), + warning: Effect = Effect.empty(), + replaceDraftBody: Effect = Effect.empty(), + areContactSuggestionsExpanded: Map = emptyMap(), + senderChangedNotice: Effect = Effect.empty() + ) = ComposerDraftState( + fields = ComposerFields( + draftId = draftId, + sender = sender, + to = to, + cc = cc, + bcc = bcc, + subject = subject.value, + body = draftBody, + quotedBody = null + ), + attachments = AttachmentGroupUiModel(attachments = emptyList()), + premiumFeatureMessage = premiumFeatureMessage, + recipientValidationError = recipientValidationError, + error = error, + isSubmittable = false, + senderAddresses = senderAddresses, + changeBottomSheetVisibility = changeSenderBottomSheetVisibility, + closeComposer = closeComposer, + closeComposerWithDraftSaved = closeComposerWithDraftSaved, + isLoading = isLoading, + closeComposerWithMessageSending = Effect.empty(), + changeFocusToField = Effect.empty(), + closeComposerWithMessageSendingOffline = Effect.empty(), + confirmSendingWithoutSubject = Effect.empty(), + attachmentsFileSizeExceeded = attachmentsFileSizeExceeded, + attachmentsReEncryptionFailed = attachmentReEncryptionFailed, + warning = warning, + replaceDraftBody = replaceDraftBody, + areContactSuggestionsExpanded = areContactSuggestionsExpanded, + isMessagePasswordSet = false, + senderChangedNotice = senderChangedNotice, + messageExpiresIn = Duration.ZERO, + confirmSendExpiringMessage = Effect.empty(), + isDeviceContactsSuggestionsEnabled = false, + isDeviceContactsSuggestionsPromptEnabled = false, + openImagePicker = Effect.empty(), + shouldRestrictWebViewHeight = false + ) + + private fun aPositiveRandomInt(bound: Int = 10) = Random().nextInt(bound) + + private fun aMultipleRandomRange(lowerBound: Int = 2, upperBound: Int = 10) = + lowerBound until aPositiveRandomInt(upperBound) + 2 * lowerBound + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> = transitions.map { test -> arrayOf(test.name, test) } + + data class TestTransition( + val name: String, + val currentState: ComposerDraftState, + val operation: ComposerOperation, + val expectedState: ComposerDraftState + ) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducerTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducerTest.kt new file mode 100644 index 0000000000..6f128163f7 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/reducer/SetMessagePasswordReducerTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.reducer + +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.presentation.model.MessagePasswordOperation +import ch.protonmail.android.mailcomposer.presentation.model.SetMessagePasswordState +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.testdata.user.UserIdTestData +import me.proton.core.util.kotlin.EMPTY_STRING +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.assertEquals + +@RunWith(Parameterized::class) +class SetMessagePasswordReducerTest( + private val testName: String, + private val testInput: TestInput +) { + + private val setMessagePasswordReducer = SetMessagePasswordReducer() + + @Test + fun `should produce the expected new state`() = with(testInput) { + val actualState = setMessagePasswordReducer.newStateFrom(currentState, operation) + + assertEquals(expectedState, actualState, testName) + } + + companion object { + + private const val Password = "password" + private const val Hint = "hint" + private val messagePassword = MessagePassword(UserIdTestData.userId, MessageIdSample.EmptyDraft, Password, Hint) + + private val testInputList = listOf( + TestInput( + currentState = SetMessagePasswordState.Loading, + operation = MessagePasswordOperation.Event.InitializeScreen(messagePassword), + expectedState = SetMessagePasswordState.Data( + initialMessagePasswordValue = Password, + initialMessagePasswordHintValue = Hint, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = true, + exitScreen = Effect.empty() + ) + ), + TestInput( + currentState = SetMessagePasswordState.Loading, + operation = MessagePasswordOperation.Event.InitializeScreen(null), + expectedState = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + ), + TestInput( + currentState = SetMessagePasswordState.Data( + initialMessagePasswordValue = Password, + initialMessagePasswordHintValue = Hint, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = true, + exitScreen = Effect.empty() + ), + operation = MessagePasswordOperation.Event.ExitScreen, + expectedState = SetMessagePasswordState.Data( + initialMessagePasswordValue = Password, + initialMessagePasswordHintValue = Hint, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = true, + exitScreen = Effect.of(Unit) + ) + ), + TestInput( + currentState = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ), + operation = MessagePasswordOperation.Event.PasswordValidated(hasMessagePasswordError = true), + expectedState = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = true, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + ), + TestInput( + currentState = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ), + operation = MessagePasswordOperation.Event.RepeatedPasswordValidated( + hasRepeatedMessagePasswordError = true + ), + expectedState = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = true, + isInEditMode = false, + exitScreen = Effect.empty() + ) + ) + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = testInputList + .map { testInput -> + val testName = """ + Current state: ${testInput.currentState} + Operation: ${testInput.operation} + Next state: ${testInput.expectedState} + + """.trimIndent() + arrayOf(testName, testInput) + } + } + + data class TestInput( + val currentState: SetMessagePasswordState, + val operation: MessagePasswordOperation.Event, + val expectedState: SetMessagePasswordState + ) +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignatureTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignatureTest.kt new file mode 100644 index 0000000000..aadac6fa86 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/InjectAddressSignatureTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.SignatureFooterSeparator +import ch.protonmail.android.mailsettings.domain.model.MobileFooter +import ch.protonmail.android.mailsettings.domain.model.Signature +import ch.protonmail.android.mailsettings.domain.model.SignatureValue +import ch.protonmail.android.mailsettings.domain.usecase.identity.GetAddressSignature +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.model.toPlainText +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.usecase.GetMobileFooter +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals + +class InjectAddressSignatureTest { + + private val getAddressSignatureMock = mockk() + private val getMobileFooterMock = mockk() + + private val injectAddressSignature = InjectAddressSignature(getAddressSignatureMock, getMobileFooterMock) + + private val paidMobileFooter = "" + private val freeMobileFooter = "Sent from Proton Mail Android" + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `returns draft body with injected signature when previous signature was found, free user`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val previousSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedSignature = expectSignatureForSenderAddress(userId, senderEmail).value + val expectedPreviousSignature = expectSignatureForSenderAddress(userId, previousSenderEmail).value + val expectedMobileFooter = expectMobileFooter(userId, isUserPaid = false) + val existingBody = DraftBody( + "The body of my important message, originally with signature of previous sender." + + expectedPreviousSignature.toPlainText() + + SignatureFooterSeparator + + expectedMobileFooter + ) + + // When + val actual = injectAddressSignature(userId, existingBody, senderEmail, previousSenderEmail).getOrNull()!! + + // Then + val expectedBodyWithSignature = DraftBody( + "The body of my important message, originally with signature of previous sender." + + expectedSignature.toPlainText() + + SignatureFooterSeparator + + expectedMobileFooter + ) + + assertEquals(expectedBodyWithSignature, actual) + } + + @Test + fun `returns draft body with injected signature when previous signature was not found, free user`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val previousSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedSignature = expectSignatureForSenderAddress(userId, senderEmail).value + expectSignatureForSenderAddress(userId, previousSenderEmail) + val expectedMobileFooter = expectMobileFooter(userId, isUserPaid = false) + val existingBody = DraftBody( + "The body of my important message." + + SignatureFooterSeparator + + expectedMobileFooter + ) + + // When + val actual = injectAddressSignature(userId, existingBody, senderEmail, previousSenderEmail).getOrNull()!! + + // Then + val expectedBodyWithSignature = DraftBody( + "The body of my important message." + + SignatureFooterSeparator + + expectedSignature.toPlainText() + + SignatureFooterSeparator + + expectedMobileFooter + ) + + assertEquals(expectedBodyWithSignature, actual) + } + + @Test + fun `returns draft body with injected blank signature into blank draft body, paid user`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSignature = expectBlankSignatureForSenderAddress(userId, senderEmail) + val existingBody = DraftBody("") + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = injectAddressSignature(userId, existingBody, senderEmail).getOrNull()!! + + // Then + val expectedBodyWithSignature = DraftBody(paidMobileFooter) + + assertEquals(expectedBodyWithSignature, actual) + } + + @Test + fun `returns draft body with injected blank signature into blank draft body, free user`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSignature = expectBlankSignatureForSenderAddress(userId, senderEmail) + val existingBody = DraftBody("") + expectMobileFooter(userId, isUserPaid = false) + + // When + val actual = injectAddressSignature(userId, existingBody, senderEmail).getOrNull()!! + + // Then + val expectedBodyWithSignature = DraftBody(SignatureFooterSeparator + freeMobileFooter) + + assertEquals(expectedBodyWithSignature, actual) + } + + @Test + fun `returns draft body with no signature if disabled into blank draft body, free user`() = runTest { + // Given + val userId = UserIdSample.Primary + val senderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectSignatureForSenderAddress(userId, senderEmail, enabled = false) + val existingBody = DraftBody("") + expectMobileFooter(userId, isUserPaid = false) + + // When + val actual = injectAddressSignature(userId, existingBody, senderEmail).getOrNull()!! + + // Then + val expectedBodyWithSignature = DraftBody(SignatureFooterSeparator + freeMobileFooter) + + assertEquals(expectedBodyWithSignature, actual) + } + + private fun expectSignatureForSenderAddress( + expectedUserId: UserId, + expectedSenderEmail: SenderEmail, + enabled: Boolean = true + ): Signature = Signature( + enabled = enabled, + SignatureValue("
HTML signature ($expectedSenderEmail)
") + ).also { + coEvery { getAddressSignatureMock(expectedUserId, expectedSenderEmail.value) } returns it.right() + } + + private fun expectBlankSignatureForSenderAddress( + expectedUserId: UserId, + expectedSenderEmail: SenderEmail + ): Signature = Signature( + enabled = true, + SignatureValue("") + ).also { + coEvery { getAddressSignatureMock(expectedUserId, expectedSenderEmail.value) } returns it.right() + } + + private fun expectMobileFooter(expectedUserId: UserId, isUserPaid: Boolean): String { + + val footer = if (isUserPaid) { + MobileFooter.PaidUserMobileFooter(paidMobileFooter, enabled = true) + } else { + MobileFooter.FreeUserMobileFooter(freeMobileFooter) + } + + coEvery { getMobileFooterMock(expectedUserId) } returns footer.right() + return footer.value + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFieldsTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFieldsTest.kt new file mode 100644 index 0000000000..9d8ec11707 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/ParentMessageToDraftFieldsTest.kt @@ -0,0 +1,625 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import android.content.Context +import androidx.annotation.StringRes +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.ObserveUserAddresses +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.usecase.FormatExtendedTime +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.CloseProtonMailBlockquote +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.CloseProtonMailQuote +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.LineBreak +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.ProtonMailBlockquote +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields.Companion.ProtonMailQuote +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample +import ch.protonmail.android.mailsettings.domain.model.MobileFooter +import ch.protonmail.android.mailsettings.domain.model.Signature +import ch.protonmail.android.mailsettings.domain.model.SignatureValue +import ch.protonmail.android.mailsettings.domain.usecase.identity.GetAddressSignature +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.model.toPlainText +import ch.protonmail.android.mailsettings.presentation.accountsettings.identity.usecase.GetMobileFooter +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.user.domain.entity.UserAddress +import org.junit.After +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +internal class ParentMessageToDraftFieldsTest { + + private val observeUserAddresses = mockk() + private val context = mockk() + private val formatTime = mockk() + private val getAddressSignatureMock = mockk() + private val getMobileFooterMock = mockk() + private val subjectWithPrefixForAction = SubjectWithPrefixForAction() + + private val expectedOriginalMessageRes = expectStringRes(R.string.composer_original_message_quote) { + "Original Message" + } + private val expectedSenderQuoteRes = expectStringRes(R.string.composer_sender_quote) { + "On %s, %s < %s> wrote:" + } + + private val paidMobileFooter = "" + private val freeMobileFooter = "Sent from Proton Mail Android" + + private val parentMessageToDraftFields = ParentMessageToDraftFields( + context, + observeUserAddresses, + formatTime, + getAddressSignatureMock, + getMobileFooterMock, + subjectWithPrefixForAction + ) + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `returns quoted draft body with injected sender and body data`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + val expectedTime = expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + val expectedOriginalMessageQuote = "-------- $expectedOriginalMessageRes --------" + val expectedSenderQuote = expectedSenderQuoteRes.format( + expectedTime.value, + expectedDecryptedMessage.messageWithBody.message.sender.name, + expectedDecryptedMessage.messageWithBody.message.sender.address + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + val expectedBody = expectedDecryptedMessage.decryptedMessageBody.value + expectBlankSignatureForSenderAddress(userId, SenderEmail(UserAddressSample.PrimaryAddress.email)) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expectedQuotedHtmlBody = StringBuilder() + .append(ProtonMailQuote) + .append(LineBreak) + .append(LineBreak) + .append(expectedOriginalMessageQuote) + .append(LineBreak) + .append(expectedSenderQuote) + .append(LineBreak) + .append(ProtonMailBlockquote) + .append(expectedBody) + .append(CloseProtonMailBlockquote) + .append(CloseProtonMailQuote) + .toString() + assertEquals(expectedQuotedHtmlBody, actual.originalHtmlQuote?.value) + } + + @Test + fun `returns draft body with injected sender signature for plaintext message, paid user`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.Invoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.Invoice, + DecryptedMessageBodyTestData.PlainTextDecryptedBody + ) + val expectedTime = expectFormattedTime(MessageSample.Invoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + val expectedOriginalMessageQuote = "-------- $expectedOriginalMessageRes --------" + val expectedSenderQuote = expectedSenderQuoteRes.format( + expectedTime.value, + expectedDecryptedMessage.messageWithBody.message.sender.name, + expectedDecryptedMessage.messageWithBody.message.sender.address + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + val expectedBody = "${ParentMessageToDraftFields.PlainTextQuotePrefix} " + + expectedDecryptedMessage.decryptedMessageBody.value + val expectedSignature = expectSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email) + ) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expectedQuotedPlaintextBody = StringBuilder() + .append(ParentMessageToDraftFields.SignatureFooterSeparator) + .append(expectedSignature.value.toPlainText()) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedOriginalMessageQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedSenderQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedBody) + .toString() + + assertEquals(expectedQuotedPlaintextBody, actual.body.value) + } + + @Test + fun `returns draft body with injected sender signature for plaintext message, free user`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.Invoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.Invoice, + DecryptedMessageBodyTestData.PlainTextDecryptedBody + ) + val expectedTime = expectFormattedTime(MessageSample.Invoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + val expectedOriginalMessageQuote = "-------- $expectedOriginalMessageRes --------" + val expectedSenderQuote = expectedSenderQuoteRes.format( + expectedTime.value, + expectedDecryptedMessage.messageWithBody.message.sender.name, + expectedDecryptedMessage.messageWithBody.message.sender.address + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + val expectedBody = "${ParentMessageToDraftFields.PlainTextQuotePrefix} " + + expectedDecryptedMessage.decryptedMessageBody.value + val expectedSignature = expectSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email) + ) + val expectedMobileFooter = expectMobileFooter(userId, isUserPaid = false) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expectedQuotedPlaintextBody = StringBuilder() + .append(ParentMessageToDraftFields.SignatureFooterSeparator) + .append(expectedSignature.value.toPlainText()) + .append(ParentMessageToDraftFields.SignatureFooterSeparator) + .append(expectedMobileFooter) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedOriginalMessageQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedSenderQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedBody) + .toString() + + assertEquals(expectedQuotedPlaintextBody, actual.body.value) + } + + @Test + fun `returns draft body with injected blank sender signature for plaintext message`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.Invoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.Invoice, + DecryptedMessageBodyTestData.PlainTextDecryptedBody + ) + val expectedTime = expectFormattedTime(MessageSample.Invoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + val expectedOriginalMessageQuote = "-------- $expectedOriginalMessageRes --------" + val expectedSenderQuote = expectedSenderQuoteRes.format( + expectedTime.value, + expectedDecryptedMessage.messageWithBody.message.sender.name, + expectedDecryptedMessage.messageWithBody.message.sender.address + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + val expectedBody = "${ParentMessageToDraftFields.PlainTextQuotePrefix} " + + expectedDecryptedMessage.decryptedMessageBody.value + expectBlankSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email) + ) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expectedQuotedPlaintextBody = StringBuilder() + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedOriginalMessageQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedSenderQuote) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(ParentMessageToDraftFields.PlainTextNewLine) + .append(expectedBody) + .toString() + + assertEquals(expectedQuotedPlaintextBody, actual.body.value) + } + + @Test + fun `returns draft body with injected sender signature for HTML message`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + val expectedSignature = expectSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email) + ) + val footer = expectMobileFooter(userId, isUserPaid = true) + val expectedBody = + ParentMessageToDraftFields.SignatureFooterSeparator + expectedSignature.value.toPlainText() + footer + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + assertEquals(expectedBody, actual.body.value) + } + + @Test + fun `returns draft body with injected blank sender signature for HTML message`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + expectBlankSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email) + ) + expectMobileFooter(userId, isUserPaid = true) + val expectedBody = "" + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + assertEquals(expectedBody, actual.body.value) + } + + @Test + fun `returns draft body with no sender signature for HTML message when signature is disabled`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { + TextUiModel.Text("Sep 13, 2023 3:36 PM") + } + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + expectSignatureForSenderAddress( + userId, + SenderEmail(UserAddressSample.PrimaryAddress.email), + enabled = false + ) + expectMobileFooter(userId, isUserPaid = true) + val expectedBody = "" + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + assertEquals(expectedBody, actual.body.value) + } + + @Test + fun `returns draft data with prefixed subject based on draft action`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { TextUiModel.Text("Sep 13, 2023 3:36 PM") } + expectBlankSignatureForSenderAddress(userId, SenderEmail(UserAddressSample.PrimaryAddress.email)) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expected = Subject("Re: ${expectedDecryptedMessage.messageWithBody.message.subject}") + assertEquals(expected, actual.subject) + } + + @Test + fun `returns draft data with reply-to address as to list when action is reply`() = runTest { + // Given + val userId = UserIdSample.Primary + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice, + DecryptedMessageBodyTestData.htmlInvoice + ) + expectedUserAddresses(userId) { listOf(UserAddressSample.PrimaryAddress) } + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { TextUiModel.Text("Sep 13, 2023 3:36 PM") } + expectBlankSignatureForSenderAddress(userId, SenderEmail(UserAddressSample.PrimaryAddress.email)) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, expectedDecryptedMessage, expectedAction).getOrNull()!! + + // Then + val expected = expectedDecryptedMessage.messageWithBody.messageBody.replyTo + assertEquals(listOf(expected), actual.recipientsTo.value) + } + + @Test + fun `returns draft data with sender and all to and cc recipients when action is reply all`() = runTest { + // Given + val expectedAction = DraftAction.ReplyAll(MessageIdSample.HtmlInvoice) + val expectedTo = listOf(RecipientSample.Billing) + val expectedCc = listOf(RecipientSample.Alice, RecipientSample.Billing, RecipientSample.Bob) + + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.build( + replyTo = RecipientSample.Billing, + message = MessageWithBodySample.HtmlInvoice.message.copy( + toList = listOf(RecipientSample.Alice, RecipientSample.Billing), + ccList = listOf(RecipientSample.Bob), + bccList = listOf(RecipientSample.Doe) + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc + ) + } + + @Test + fun `own address used to reply is removed from toList when action is reply all`() = runTest { + // Given + val expectedAction = DraftAction.ReplyAll(MessageIdSample.HtmlInvoice) + val expectedCc = listOf(RecipientSample.Scammer, RecipientSample.Alice) + val expectedTo = listOf(RecipientSample.Billing) + + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.build( + replyTo = RecipientSample.Billing, + message = MessageWithBodySample.Invoice.message.copy( + toList = listOf(RecipientSample.John, RecipientSample.Scammer, RecipientSample.Alice) + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc + ) + } + + @Test + fun `reply all contains replyTo in To field while other recipients are in Cc`() = runTest { + // Given + val expectedAction = DraftAction.ReplyAll(MessageIdSample.HtmlInvoice) + val expectedCc = listOf(RecipientSample.PreciWeather, RecipientSample.Alice) + val expectedTo = listOf(RecipientSample.Billing) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.build( + replyTo = RecipientSample.Billing, + message = MessageWithBodySample.Invoice.message.copy( + toList = listOf(RecipientSample.PreciWeather, RecipientSample.Alice), + ccList = listOf(RecipientSample.John) + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc + ) + } + + @Test + fun `own address is removed from Cc field when action is reply all`() = runTest { + val expectedAction = DraftAction.ReplyAll(MessageIdSample.HtmlInvoice) + val expectedTo = listOf(RecipientSample.Billing) + val expectedCc = listOf(RecipientSample.PreciWeather, RecipientSample.Alice) + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.build( + replyTo = RecipientSample.Billing, + message = MessageWithBodySample.Invoice.message.copy( + toList = listOf(RecipientSample.PreciWeather), + ccList = listOf(RecipientSample.John, RecipientSample.Alice) + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc + ) + } + + @Test + fun `returns draft fields with TO recipients from original message when replying to a sent message`() = runTest { + // Given + val expectedAction = DraftAction.Reply(MessageIdSample.HtmlInvoice) + val expectedTo = listOf(RecipientSample.John, RecipientSample.Billing, RecipientSample.Alice) + val expectedCc = emptyList() + + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice.copy( + message = MessageWithBodySample.Invoice.message.copy( + toList = listOf(RecipientSample.John, RecipientSample.Billing, RecipientSample.Alice), + labelIds = MessageWithBodySample.Invoice.message.labelIds + LabelId("2") + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc + ) + } + + @Test + fun `returns draft fields with BCC recipients from original message when reply all to a sent message`() = runTest { + // Given + val userId = UserIdSample.Primary + val johnUserAddress = UserAddressSample.build(email = RecipientSample.John.address) + val expectedAction = DraftAction.ReplyAll(MessageIdSample.HtmlInvoice) + val expectedTo = emptyList() + val expectedCc = emptyList() + val expectedBcc = listOf(RecipientSample.Billing, RecipientSample.Alice) + + val expectedDecryptedMessage = MessageWithDecryptedBody( + MessageWithBodySample.HtmlInvoice.copy( + message = MessageWithBodySample.Invoice.message.copy( + bccList = listOf(RecipientSample.Billing, RecipientSample.Alice), + labelIds = MessageWithBodySample.Invoice.message.labelIds + LabelId("2") + ) + ), + DecryptedMessageBodyTestData.htmlInvoice + ) + expectedUserAddresses(userId) { listOf(johnUserAddress) } + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { TextUiModel.Text("Sep 13, 2023 3:36 PM") } + expectBlankSignatureForSenderAddress(userId, SenderEmail(johnUserAddress.email)) + expectMobileFooter(userId, isUserPaid = true) + + // When + Then + testRecipientFieldsPrefill( + expectedDecryptedMessage, expectedAction, expectedTo, expectedCc, expectedBcc + ) + } + + private suspend fun testRecipientFieldsPrefill( + message: MessageWithDecryptedBody, + action: DraftAction, + expectedTo: List, + expectedCc: List, + expectedBcc: List = emptyList() + ) { + val userId = UserIdSample.Primary + val johnUserAddress = UserAddressSample.build(email = RecipientSample.John.address) + + expectedUserAddresses(userId) { listOf(johnUserAddress) } + expectFormattedTime(MessageSample.HtmlInvoice.time.seconds) { TextUiModel.Text("Sep 13, 2023 3:36 PM") } + expectBlankSignatureForSenderAddress(userId, SenderEmail(johnUserAddress.email)) + expectMobileFooter(userId, isUserPaid = true) + + // When + val actual = parentMessageToDraftFields(userId, message, action).getOrNull()!! + + // Then + assertEquals(expectedTo, actual.recipientsTo.value) + assertEquals(expectedCc, actual.recipientsCc.value) + assertEquals(expectedBcc, actual.recipientsBcc.value) + } + + private fun expectFormattedTime(timestamp: Duration, result: () -> TextUiModel.Text) = result().also { + every { formatTime(timestamp) } returns it + } + + private fun expectStringRes(@StringRes id: Int, result: () -> String) = result().also { + every { context.getString(id) } returns it + } + + private fun expectedUserAddresses(userId: UserId, addresses: () -> List) = addresses().also { + every { observeUserAddresses.invoke(userId) } returns flowOf(it) + } + + private fun expectSignatureForSenderAddress( + expectedUserId: UserId, + expectedSenderEmail: SenderEmail, + enabled: Boolean = true + ): Signature = Signature( + enabled = enabled, + SignatureValue("
HTML signature
") + ).also { + coEvery { getAddressSignatureMock(expectedUserId, expectedSenderEmail.value) } returns it.right() + } + + private fun expectBlankSignatureForSenderAddress( + expectedUserId: UserId, + expectedSenderEmail: SenderEmail + ): Signature = Signature( + enabled = true, + SignatureValue("") + ).also { coEvery { getAddressSignatureMock(expectedUserId, expectedSenderEmail.value) } returns it.right() } + + private fun expectMobileFooter(expectedUserId: UserId, isUserPaid: Boolean): String { + + val footer = if (isUserPaid) { + MobileFooter.PaidUserMobileFooter(paidMobileFooter, enabled = true) + } else { + MobileFooter.FreeUserMobileFooter(freeMobileFooter) + } + + coEvery { getMobileFooterMock(expectedUserId) } returns footer.right() + return footer.value + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestionsTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestionsTest.kt new file mode 100644 index 0000000000..c6a048ff92 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SortContactsForSuggestionsTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import ch.protonmail.android.mailcommon.domain.sample.LabelIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.presentation.usecase.GetInitials +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcontact.domain.model.ContactGroup +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.testdata.contact.ContactEmailSample +import ch.protonmail.android.testdata.contact.ContactEmailSample.contactEmailLastUsedLongTimeAgo +import ch.protonmail.android.testdata.contact.ContactEmailSample.contactEmailLastUsedRecently +import ch.protonmail.android.testdata.contact.ContactSample +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +class SortContactsForSuggestionsTest { + + private val getInitials = mockk() + private val sut = SortContactsForSuggestions(getInitials) + + @Before + fun mockInitials() { + every { getInitials(any()) } returns BaseInitials + } + + @Test + fun `should return correctly sorted UI models`() = runTest { + // Given + val contacts = listOf( + ContactSample.Stefano, + ContactSample.Doe.copy( + contactEmails = listOf( + contactEmailLastUsedLongTimeAgo, + contactEmailLastUsedRecently + ) + ) + ) + val deviceContacts = listOf( + DeviceContact( + "device contact 2", + "device@email 2" + ), + DeviceContact( + "device contact 1", + "device@email 1" + ) + ) + val contactGroups = listOf( + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "z group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1) + ), + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "x group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1) + ), + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "a group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1) + ) + ) + + // When + val actual = sut( + contacts, + deviceContacts, + contactGroups, + 7 // one fewer than total + ) + + // Then + val expected = listOf( + ContactSuggestionUiModel.Contact( + name = contacts[1].contactEmails[1].name, + initial = BaseInitials, + email = contacts[1].contactEmails[1].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[1].contactEmails[0].name, + initial = BaseInitials, + email = contacts[1].contactEmails[0].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[0].contactEmails[0].name, + initial = BaseInitials, + email = contacts[0].contactEmails[0].email + ), + ContactSuggestionUiModel.ContactGroup( + name = contactGroups[2].name, + emails = contactGroups[2].members.map { it.email }, + color = "#AABBCC" + ), + ContactSuggestionUiModel.Contact( + name = deviceContacts[1].name, + initial = BaseInitials, + email = deviceContacts[1].email + ), + ContactSuggestionUiModel.Contact( + name = deviceContacts[0].name, + initial = BaseInitials, + email = deviceContacts[0].email + ), + ContactSuggestionUiModel.ContactGroup( + contactGroups[1].name, + contactGroups[1].members.map { it.email }, + color = "#AABBCC" + ) + ) + + assertEquals(expected, actual) + } + + @Test + fun `should remove duplicates when email exists both in Proton and device contacts`() = runTest { + // Given + val firstEmail = ContactEmailSample.contactEmail1.copy(email = "email1@proton.me") + val secondEmail = ContactEmailSample.contactEmail1.copy(email = "email2@proton.me") + val thirdEmail = ContactEmailSample.contactEmail1.copy(email = "email3@proton.me") + val fourthEmail = ContactEmailSample.contactEmail1.copy(email = "email4@proton.me") + val fifthEmail = "email5@proton.me" + + val contacts = listOf( + ContactSample.Doe.copy(contactEmails = listOf(firstEmail, secondEmail)), + ContactSample.Doe.copy(contactEmails = listOf(thirdEmail)) + ) + val deviceContacts = listOf( + DeviceContact("First Email equivalent", firstEmail.email), + DeviceContact("Second Email equivalent", secondEmail.email), + DeviceContact("New contact", fourthEmail.email) + ) + + val groupsSuggestions = listOf( + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "A group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1.copy(email = fifthEmail)) + ) + ) + + val expectedSuggestionsResult = listOf( + ContactSuggestionUiModel.Contact( + name = contacts[0].contactEmails[0].name, + initial = BaseInitials, + email = contacts[0].contactEmails[0].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[0].contactEmails[1].name, + initial = BaseInitials, + email = contacts[0].contactEmails[1].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[1].contactEmails[0].name, + initial = BaseInitials, + email = contacts[1].contactEmails[0].email + ), + ContactSuggestionUiModel.ContactGroup( + name = groupsSuggestions[0].name, + emails = groupsSuggestions[0].members.map { it.email }, + color = "#AABBCC" + ), + ContactSuggestionUiModel.Contact( + name = deviceContacts[2].name, + initial = BaseInitials, + email = deviceContacts[2].email + ) + ) + + // When + val actual = sut( + contacts, + deviceContacts, + groupsSuggestions, + 50 + ) + + // Then + assertEquals(actual, expectedSuggestionsResult) + } + + @Test + fun `should not remove duplicates from contact groups when email exists both in group and device contacts`() = + runTest { + // Given + val firstEmail = ContactEmailSample.contactEmail1.copy(email = "email1@proton.me") + val secondEmail = ContactEmailSample.contactEmail1.copy(email = "email2@proton.me") + val thirdEmail = ContactEmailSample.contactEmail1.copy(email = "email3@proton.me") + val fourthEmail = ContactEmailSample.contactEmail1.copy(email = "email4@proton.me") + + val contacts = listOf( + ContactSample.Doe.copy(contactEmails = listOf(firstEmail, secondEmail)), + ContactSample.Doe.copy(contactEmails = listOf(thirdEmail)) + ) + val deviceContacts = listOf( + DeviceContact("First Email equivalent", firstEmail.email), + DeviceContact("Second Email equivalent", secondEmail.email), + DeviceContact("New contact", fourthEmail.email) + ) + + val groupsSuggestions = listOf( + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "A group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1.copy(email = firstEmail.email)) + ) + ) + + val expectedSuggestionsResult = listOf( + ContactSuggestionUiModel.Contact( + name = contacts[0].contactEmails[0].name, + initial = BaseInitials, + email = contacts[0].contactEmails[0].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[0].contactEmails[1].name, + initial = BaseInitials, + email = contacts[0].contactEmails[1].email + ), + ContactSuggestionUiModel.Contact( + name = contacts[1].contactEmails[0].name, + initial = BaseInitials, + email = contacts[1].contactEmails[0].email + ), + ContactSuggestionUiModel.ContactGroup( + groupsSuggestions[0].name, + groupsSuggestions[0].members.map { it.email }, + color = "#AABBCC" + ), + ContactSuggestionUiModel.Contact( + name = deviceContacts[2].name, + initial = BaseInitials, + email = deviceContacts[2].email + ) + ) + + // When + val actual = sut( + contacts, + deviceContacts, + groupsSuggestions, + 50 + ) + + // Then + assertEquals(actual, expectedSuggestionsResult) + } + + private companion object { + + const val BaseInitials = "AB" + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForActionTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForActionTest.kt new file mode 100644 index 0000000000..a55976177a --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/usecase/SubjectWithPrefixForActionTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.usecase + +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SubjectWithPrefixForActionTest { + + private val subjectWithPrefixForAction = SubjectWithPrefixForAction() + + @Test + fun `should return subject as it is for Compose action`() = runTest { + val action = DraftAction.Compose + val subject = "Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should return subject as it is for PrefillForShare action`() = runTest { + val action = DraftAction.PrefillForShare(IntentShareInfo.Empty) + val subject = "Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should return subject as it is for ComposeToAddresses action`() = runTest { + val action = DraftAction.ComposeToAddresses(emptyList()) + val subject = "Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should add forward prefix to subject for Forward action if not already present`() = runTest { + val action = DraftAction.Forward(MessageIdSample.AugWeatherForecast) + val subject = "Subject Line" + val expectedSubject = "Fw: $subject" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(expectedSubject, result) + } + + @Test + fun `should not add forward prefix to subject for Forward action if already present`() = runTest { + val action = DraftAction.Forward(MessageIdSample.AugWeatherForecast) + val subject = "Fw: Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should add reply prefix to subject for Reply action if not already present`() = runTest { + val action = DraftAction.Reply(MessageIdSample.AugWeatherForecast) + val subject = "Subject Line" + val expectedSubject = "Re: $subject" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(expectedSubject, result) + } + + @Test + fun `should not add reply prefix to subject for Reply action if already present`() = runTest { + val action = DraftAction.Reply(MessageIdSample.AugWeatherForecast) + val subject = " Re: Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should add reply prefix to subject for ReplyAll action if not already present`() = runTest { + val action = DraftAction.ReplyAll(MessageIdSample.AugWeatherForecast) + val subject = "Subject Line" + val expectedSubject = "Re: $subject" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(expectedSubject, result) + } + + @Test + fun `should not add reply prefix to subject for ReplyAll action if reply prefix already present`() = runTest { + val action = DraftAction.ReplyAll(MessageIdSample.AugWeatherForecast) + val subject = "Re: Subject Line" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(subject, result) + } + + @Test + fun `should add reply prefix when replying a subject with forward prefix`() = runTest { + val action = DraftAction.Reply(MessageIdSample.AugWeatherForecast) + val subject = " Fw: Subject" + val expectedSubject = "Re: $subject" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(expectedSubject, result) + } + + @Test + fun `should add forward prefix when forwarding a subject with reply prefix`() = runTest { + val action = DraftAction.Forward(MessageIdSample.AugWeatherForecast) + val subject = "Re: Subject" + val expectedSubject = "Fw: $subject" + + val result = subjectWithPrefixForAction(action, subject) + + assertEquals(expectedSubject, result) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModelTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModelTest.kt new file mode 100644 index 0000000000..e7a4285b31 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerChipsListViewModelTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import app.cash.turbine.test +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import ch.protonmail.android.uicomponents.chips.item.ChipItem +import ch.protonmail.android.uicomponents.chips.item.ChipItemsList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class ComposerChipsListViewModelTest { + + private lateinit var viewModel: ComposerChipsListViewModel + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + @BeforeTest + fun setup() { + viewModel = ComposerChipsListViewModel() + } + + @Test + fun `when adding an item it emits the updated state`() = runTest { + // Given + val newItem = ChipItem.Valid("aa@bb.cc") + + // When + Then + viewModel.state.test { + viewModel.state.value.listState.updateItems(listOf(newItem)) + + val newState = awaitItem() + assertEquals(ChipItemsList.Unfocused.Single(newItem), newState.listState.getItems()) + assertEquals(Effect.empty(), newState.duplicateRemovalWarning) + assertEquals(Effect.empty(), newState.invalidEntryWarning) + } + } + + @Test + fun `when adding a duplicate item it emits the error state`() = runTest { + // Given + val newItem = ChipItem.Valid("aa@bb.cc") + val expectedDuplicatedEffect = Effect.of(TextUiModel(R.string.composer_error_duplicate_recipient)) + + // When + Then + viewModel.state.test { + repeat(2) { + viewModel.state.value.listState.type(newItem.value) + viewModel.state.value.listState.type("\n") + } + + skipItems(2) + + val newState = awaitItem() + assertEquals(ChipItemsList.Unfocused.Single(newItem), newState.listState.getItems()) + assertEquals(expectedDuplicatedEffect, newState.duplicateRemovalWarning) + assertEquals(Effect.empty(), newState.invalidEntryWarning) + } + } + + @Test + fun `when adding an invalid item it emits the invalid entry warning`() = runTest { + // Given + val newItem = ChipItem.Invalid("__") + val expectedInvalidEffect = Effect.of(TextUiModel(R.string.composer_error_invalid_email)) + + // When + Then + viewModel.state.test { + viewModel.state.value.listState.type(newItem.value) + viewModel.state.value.listState.type("\n") + + skipItems(1) + + val state = awaitItem() + assertEquals(ChipItemsList.Unfocused.Single(newItem), state.listState.getItems()) + assertEquals(Effect.empty(), state.duplicateRemovalWarning) + assertEquals(expectedInvalidEffect, state.invalidEntryWarning) + + cancelAndConsumeRemainingEvents() + } + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2ActionsTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2ActionsTest.kt new file mode 100644 index 0000000000..567432040f --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2ActionsTest.kt @@ -0,0 +1,1180 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import android.net.Uri +import android.os.Build +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.facade.AddressesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.AttachmentsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.DraftFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageAttributesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageContentFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageParticipantsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageSendingFacade +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.FocusedFieldType +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.model.operations.AccessoriesEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.AttachmentsEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.ComposerAction2 +import ch.protonmail.android.mailcomposer.presentation.model.operations.CompositeEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.EffectsEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.EffectsEvent.ComposerControlEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.MainEvent +import ch.protonmail.android.mailcomposer.presentation.reducer.ComposerStateReducer +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.defaultDraftFields +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectDraftAction +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectParticipantsMapping +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectStandaloneDraft +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.messageId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.parentMessageId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.recipientsTo +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.userId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.verifyStates +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageAttachment +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.AttachmentUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verifySequence +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import me.proton.core.network.domain.NetworkManager +import org.junit.Rule +import org.junit.Test +import kotlin.test.AfterTest +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +internal class ComposerViewModel2ActionsTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + private val draftFacade = mockk { + every { this@mockk.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) } just runs + } + private val attachmentsFacade = mockk(relaxed = true) + private val addressesFacade = mockk(relaxed = true) + private val messageAttributesFacade = mockk(relaxed = true) + private val messageContentFacade = mockk(relaxed = true) + private val messageParticipantsFacade = mockk { + every { this@mockk.observePrimaryUserId() } returns flowOf(userId) + } + private val messageSendingFacade = mockk(relaxed = true) + + private val appInBackgroundState = mockk { + every { this@mockk.observe() } returns flowOf(false) + } + private val savedStateHandle = mockk(relaxed = true) + private val networkManagerMock = mockk(relaxed = true) + private val composerStateReducer = spyk() + private val recipientsStateManager = spyk() + private val shouldRestrictWebViewHeight = mockk { + every { this@mockk.invoke(null) } returns false + } + private val buildVersionProvider = mockk { + every { sdkInt() } returns Build.VERSION_CODES.S + } + + private fun viewModel(): ComposerViewModel2 = ComposerViewModel2( + draftFacade, + attachmentsFacade, + messageAttributesFacade, + messageContentFacade, + messageParticipantsFacade, + messageSendingFacade, + addressesFacade, + appInBackgroundState, + networkManagerMock, + savedStateHandle, + composerStateReducer, + testDispatcher, + recipientsStateManager, + shouldRestrictWebViewHeight, + buildVersionProvider + ) + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `should emit state to show bottomsheet and show addresses list on change sender request`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val expectedAddresses = listOf( + UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress + ) + coEvery { addressesFacade.getSenderAddresses() } returns expectedAddresses.right() + + val initialMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalMainState = initialMainState.copy( + senderAddresses = expectedAddresses.map { SenderUiModel(it.email) }.toImmutableList() + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + changeBottomSheetVisibility = Effect.of(true) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + verifyStates(main = initialMainState, actualStates = awaitItem()) + + viewModel.submitAction(ComposerAction2.ChangeSender) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should emit state to show error on change sender request with no addresses (unknown permissions)`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + coEvery { + addressesFacade.getSenderAddresses() + } returns GetComposerSenderAddresses.Error.FailedGettingPrimaryUser.left() + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + error = Effect.of(TextUiModel(R.string.composer_error_change_sender_failed_getting_subscription)) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.ChangeSender) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should emit state to show warning on change sender request with free user`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + coEvery { + addressesFacade.getSenderAddresses() + } returns GetComposerSenderAddresses.Error.UpgradeToChangeSender.left() + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + premiumFeatureMessage = Effect.of(TextUiModel(R.string.composer_change_sender_paid_feature)) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.ChangeSender) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should emit state to set new sender address`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val newSender = SenderEmail("new-sender@proton.me") + coEvery { + attachmentsFacade.reEncryptAttachments(userId, messageId, defaultDraftFields.sender, newSender) + } returns Unit.right() + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(newSender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + changeBottomSheetVisibility = Effect.of(false) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.SetSenderAddress(SenderUiModel(newSender.value))) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should update the state to show the bottomsheet when requesting the expiration screen`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + changeBottomSheetVisibility = Effect.of(true) + ) + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.OpenExpirationSettings) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should update the state to hide the bottomsheet when the expiration is being set`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val draftFields = emptyDraftFields() + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value) + ) + + val finalAccessoriesState = ComposerState.Accessories.initial().copy( + messageExpiresIn = 1.hours + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + changeBottomSheetVisibility = Effect.of(false) + ) + + coEvery { + messageAttributesFacade.saveMessageExpiration(userId, messageId, draftFields.sender, 1.hours) + } returns Unit.right() + + coEvery { + draftFacade.storeDraft(userId, messageId, draftFields, any()) + } returns Unit.right() + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.SetMessageExpiration(1.hours)) + verifyStates( + main = finalMainState, + accessories = finalAccessoriesState, + effects = finalEffectsState, + actualStates = awaitItem() + ) + } + } + + @Test + fun `should update the state to show an error when the expiration can't be set`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + error = Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)), + changeBottomSheetVisibility = Effect.of(false) + ) + + coEvery { + messageAttributesFacade.saveMessageExpiration(userId, messageId, defaultDraftFields.sender, 1.hours) + } returns DataError.Local.Unknown.left() + + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.SetMessageExpiration(1.hours)) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should update the state to open the file picker on attachments icon tap`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + openImagePicker = Effect.of(Unit) + ) + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + + viewModel.submitAction(ComposerAction2.OpenFilePicker) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should proxy the call to store attachments when receiving the store attachments action`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val attachmentsList: List = listOf(mockk()) + + coEvery { + attachmentsFacade.storeAttachments(userId, messageId, defaultDraftFields.sender, attachmentsList) + } returns Unit.right() + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + verifyStates(main = finalMainState, actualStates = awaitItem()) + viewModel.submitAction(ComposerAction2.StoreAttachments(attachmentsList)) + } + + coVerify { + attachmentsFacade.storeAttachments(userId, messageId, defaultDraftFields.sender, attachmentsList) + } + } + + @Test + fun `should emit an error when attachments can't be stored`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + error = Effect.of(TextUiModel.TextRes(R.string.composer_attachment_error_saving_attachment)) + ) + + coEvery { + attachmentsFacade.storeAttachments(userId, messageId, defaultDraftFields.sender, any()) + } returns StoreDraftWithAttachmentError.FailedToStoreAttachments.left() + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + viewModel.submitAction(ComposerAction2.StoreAttachments(listOf(mockk()))) + + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should update the state when cancelling sending with not subject`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + changeFocusToField = Effect.of(FocusedFieldType.SUBJECT), + confirmSendingWithoutSubject = Effect.empty() + ) + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + skipItems(1) + viewModel.submitAction(ComposerAction2.CancelSendWithNoSubject) + + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + } + + @Test + fun `should keep the state clear when clearing sending error upon request`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + coEvery { messageSendingFacade.clearMessageSendingError(userId, messageId) } returns Unit.right() + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + viewModel.submitAction(ComposerAction2.ClearSendingError) + verifyStates(main = finalMainState, actualStates = awaitItem()) + } + + coVerify { messageSendingFacade.clearMessageSendingError(userId, messageId) } + } + + @Test + fun `should proxy the call to remove an attachment when receiving the remove attachment action`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val attachment = mockk() + + coEvery { + attachmentsFacade.deleteAttachment(userId, messageId, defaultDraftFields.sender, attachment) + } returns Unit.right() + + // When + Then + val viewModel = viewModel() + viewModel.composerStates.test { + verifyStates(main = finalMainState, actualStates = awaitItem()) + viewModel.submitAction(ComposerAction2.RemoveAttachment(attachment)) + } + + coVerify { + attachmentsFacade.deleteAttachment(userId, messageId, defaultDraftFields.sender, attachment) + } + } + + @Test + fun `should stop draft uploader when app goes to the background`() = runTest { + // Given + expectStandaloneDraft(savedStateHandle) + every { draftFacade.provideNewDraftId() } returns messageId + val appInBackground = MutableSharedFlow() + + every { appInBackgroundState.observe() } returns appInBackground + every { draftFacade.stopContinuousUpload() } just runs + + @Suppress("Unused") val viewModel = viewModel() + + // When + appInBackground.emit(true) + + // Then + coVerifySequence { + draftFacade.provideNewDraftId() + draftFacade.stopContinuousUpload() + } + + confirmVerified(draftFacade) + } + + @Test + fun `should start draft uploader when app is back to the background`() = runTest { + // Given + expectStandaloneDraft(savedStateHandle) + every { draftFacade.provideNewDraftId() } returns messageId + val appInBackground = MutableSharedFlow() + + every { appInBackgroundState.observe() } returns appInBackground + every { draftFacade.stopContinuousUpload() } just runs + + @Suppress("Unused") val viewModel = viewModel() + + // When + appInBackground.emit(true) + appInBackground.emit(false) + appInBackground.emit(true) + + // Then + verifySequence { + draftFacade.provideNewDraftId() + draftFacade.stopContinuousUpload() + draftFacade.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + draftFacade.stopContinuousUpload() + } + } + + @Test + fun `should show empty subject warning when sending without a subject`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + expectParticipantsMapping(messageParticipantsFacade) + + val recipients = listOf(RecipientUiModel.Valid(recipientsTo.first().address)) + recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.TO) + + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + + val viewModel = viewModel() + viewModel.submitAction(ComposerAction2.SendMessage) + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(defaultDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = true)) + composerStateReducer.reduceNewState(any(), MainEvent.CoreLoadingToggled) + composerStateReducer.reduceNewState(any(), CompositeEvent.OnSendWithEmptySubject) + } + + coVerify(exactly = 0) { messageSendingFacade.sendMessage(any(), any(), any()) } + } + + @Test + fun `should close composer and skip saving draft when fields are unchanged`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + // When + Then + verifyDraftSave(viewModel(), defaultDraftFields, shouldSaveDraft = false, draftAction = DraftAction.Compose) { + // no-op + } + } + + @Test + fun `should close composer, save draft and force upload when body has changed`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val draftBody = DraftBody("draft-body") + val expectedDraftFields = emptyDraftFields().copy(body = draftBody) + + coEvery { draftFacade.stopContinuousUpload() } just runs + coEvery { draftFacade.storeDraft(userId, messageId, expectedDraftFields, any()) } returns Unit.right() + coEvery { draftFacade.forceUpload(userId, messageId) } just runs + + val viewModel = viewModel() + + // When + Then + verifyDraftSave(viewModel, expectedDraftFields, shouldSaveDraft = true, draftAction = DraftAction.Compose) { + viewModel.bodyFieldText.edit { append(draftBody.value) } + } + } + + @Test + fun `should close composer and save draft when subject has changed`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val subject = Subject("subject") + val expectedDraftFields = emptyDraftFields().copy(subject = subject) + + coEvery { draftFacade.stopContinuousUpload() } just runs + coEvery { draftFacade.storeDraft(userId, messageId, expectedDraftFields, any()) } returns Unit.right() + coEvery { draftFacade.forceUpload(userId, messageId) } just runs + + val viewModel = viewModel() + + // When + Then + verifyDraftSave(viewModel, expectedDraftFields, shouldSaveDraft = true, draftAction = DraftAction.Compose) { + viewModel.subjectTextField.edit { append(subject.value) } + } + } + + @Test + fun `should replace body when respond inline is requested`() = runTest { + // Given + val draftAction = DraftAction.Reply(parentMessageId) + val parentMessage = mockk() + val draftFields = defaultDraftFields.copy( + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()) + ) + + expectDraftAction( + draftFacade, + addressesFacade, + messageContentFacade, + savedStateHandle, + draftAction, + parentMessage, + draftFields + ) + + expectParticipantsMapping(messageParticipantsFacade) + val expectedPlainText = "\nPlain Text" + coEvery { messageContentFacade.convertHtmlToPlainText(any()) } returns expectedPlainText + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val firstMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + isSubmittable = true, + quotedHtmlContent = QuotedHtmlContent( + original = OriginalHtmlQuote("original-html"), + styled = StyledHtmlQuote("quoted-html") + ) + ) + + val finalMainState = firstMainState.copy( + quotedHtmlContent = null + ) + + val expectedEffectsState = ComposerState.Effects.initial().copy( + focusTextBody = Effect.of(Unit) + ) + + val expectedBody = "${defaultDraftFields.body.value}$expectedPlainText" + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(main = firstMainState, effects = expectedEffectsState, actualStates = awaitItem()) + + viewModel.submitAction(ComposerAction2.RespondInline) + advanceUntilIdle() + + verifyStates(main = finalMainState, effects = expectedEffectsState, actualStates = awaitItem()) + } + + assertEquals(expectedBody, viewModel.bodyFieldText.text.toString()) + } + + @Test + fun `should send message directly when no password or expiration is set`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + expectParticipantsMapping(messageParticipantsFacade) + + val recipients = listOf(RecipientUiModel.Valid(recipientsTo.first().address)) + val recipientsTo = recipientsTo.first().let { Recipient(it.address, it.name) } + recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.TO) + + val draftFields = emptyDraftFields().copy( + sender = defaultDraftFields.sender, + recipientsTo = RecipientsTo(listOf(recipientsTo)), + subject = Subject("Subject"), + body = DraftBody("Body") + ) + + every { draftFacade.stopContinuousUpload() } just runs + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + every { networkManagerMock.isConnectedToNetwork() } returns true + + val firstMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = true + ) + + val finalMainState = firstMainState.copy( + loadingType = ComposerState.LoadingType.Save + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + closeComposerWithMessageSending = Effect.of(Unit) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + viewModel.subjectTextField.edit { append(draftFields.subject.value) } + viewModel.bodyFieldText.edit { append(draftFields.body.value) } + + viewModel.submitAction(ComposerAction2.SendMessage) + advanceUntilIdle() + + verifyStates(main = firstMainState, actualStates = awaitItem()) + verifyStates(main = finalMainState, actualStates = awaitItem()) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(draftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = true)) + composerStateReducer.reduceNewState(any(), MainEvent.CoreLoadingToggled) + composerStateReducer.reduceNewState(any(), EffectsEvent.SendEvent.OnSendMessage) + } + + coVerify(exactly = 1) { + draftFacade.stopContinuousUpload() + messageSendingFacade.sendMessage(userId, messageId, draftFields) + } + } + + @Test + fun `should schedule send when no password or expiration is set and user is offline`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + expectParticipantsMapping(messageParticipantsFacade) + + val recipients = listOf(RecipientUiModel.Valid(recipientsTo.first().address)) + val recipientsTo = recipientsTo.first().let { Recipient(it.address, it.name) } + recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.TO) + + val draftFields = emptyDraftFields().copy( + sender = defaultDraftFields.sender, + recipientsTo = RecipientsTo(listOf(recipientsTo)), + subject = Subject("Subject"), + body = DraftBody("Body") + ) + + every { draftFacade.stopContinuousUpload() } just runs + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + every { networkManagerMock.isConnectedToNetwork() } returns false + + val firstMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = true + ) + + val finalMainState = firstMainState.copy( + loadingType = ComposerState.LoadingType.Save + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + closeComposerWithMessageSendingOffline = Effect.of(Unit) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + viewModel.subjectTextField.edit { append(draftFields.subject.value) } + viewModel.bodyFieldText.edit { append(draftFields.body.value) } + + viewModel.submitAction(ComposerAction2.SendMessage) + advanceUntilIdle() + + verifyStates(main = firstMainState, actualStates = awaitItem()) + verifyStates(main = finalMainState, actualStates = awaitItem()) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(draftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = true)) + composerStateReducer.reduceNewState(any(), MainEvent.CoreLoadingToggled) + composerStateReducer.reduceNewState(any(), EffectsEvent.SendEvent.OnOfflineSendMessage) + } + + coVerify(exactly = 1) { + draftFacade.stopContinuousUpload() + messageSendingFacade.sendMessage(userId, messageId, draftFields) + } + } + + @Test + fun `should notify sending to external recipients when expiration is set`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + expectParticipantsMapping(messageParticipantsFacade) + + val recipients = listOf(RecipientUiModel.Valid(recipientsTo.first().address)) + val mappedRecipients = recipientsTo.first().let { Recipient(it.address, it.name) } + + val sharedFlowExpiration = MutableSharedFlow() + coEvery { messageAttributesFacade.observeMessageExpiration(userId, messageId) } returns sharedFlowExpiration + + recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.TO) + + val mockList: List = listOf(mockk()) + coEvery { + messageParticipantsFacade.getExternalRecipients(userId, RecipientsTo(recipientsTo), any(), any()) + } returns mockList + + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + + val draftFields = emptyDraftFields().copy( + sender = defaultDraftFields.sender, + recipientsTo = RecipientsTo(listOf(mappedRecipients)), + subject = Subject("Subject"), + body = DraftBody("Body") + ) + + val firstMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = true + ) + + val accessoriesState = ComposerState.Accessories.initial().copy( + messageExpiresIn = 1.hours + ) + + val finalMainState = firstMainState.copy( + loadingType = ComposerState.LoadingType.Save + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + confirmSendExpiringMessage = Effect.of(mockList) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + sharedFlowExpiration.emit(MessageExpirationTime(userId, messageId, 1.hours)) + viewModel.subjectTextField.edit { append(draftFields.subject.value) } + viewModel.bodyFieldText.edit { append(draftFields.body.value) } + + viewModel.submitAction(ComposerAction2.SendMessage) + advanceUntilIdle() + + verifyStates(main = firstMainState, actualStates = awaitItem()) + verifyStates(main = firstMainState, accessories = accessoriesState, actualStates = awaitItem()) + verifyStates(main = finalMainState, accessories = accessoriesState, actualStates = awaitItem()) + verifyStates( + main = finalMainState, + accessories = accessoriesState, + effects = finalEffectsState, + actualStates = awaitItem() + ) + } + + coVerify(exactly = 0) { messageSendingFacade.sendMessage(any(), any(), any()) } + } + + @Test + fun `should send normally sending to external recipients when expiration and password are set`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + expectParticipantsMapping(messageParticipantsFacade) + + val recipients = listOf(RecipientUiModel.Valid(recipientsTo.first().address)) + val mappedRecipients = recipientsTo.first().let { Recipient(it.address, it.name) } + + val sharedFlowExpiration = MutableSharedFlow() + val expectedMessagePassword = MessagePassword(userId, messageId, "password", null) + coEvery { messageAttributesFacade.observeMessageExpiration(userId, messageId) } returns sharedFlowExpiration + + val sharedFlowPassword = MutableSharedFlow() + val expectedMessageExpiration = MessageExpirationTime(userId, messageId, 1.hours) + coEvery { messageAttributesFacade.observeMessagePassword(userId, messageId) } returns sharedFlowPassword + + val mockList: List = listOf(mockk()) + coEvery { + messageParticipantsFacade.getExternalRecipients(userId, RecipientsTo(recipientsTo), any(), any()) + } returns mockList + + every { draftFacade.stopContinuousUpload() } just runs + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + every { networkManagerMock.isConnectedToNetwork() } returns true + + val draftFields = emptyDraftFields().copy( + sender = defaultDraftFields.sender, + recipientsTo = RecipientsTo(listOf(mappedRecipients)), + subject = Subject("Subject"), + body = DraftBody("Body") + ) + + val firstMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = true + ) + + val firstAccessoriesState = ComposerState.Accessories.initial().copy( + messageExpiresIn = 1.hours + ) + + val finalAccessoriesState = firstAccessoriesState.copy( + isMessagePasswordSet = true + ) + + val finalMainState = firstMainState.copy( + loadingType = ComposerState.LoadingType.Save + ) + + val finalEffectsState = ComposerState.Effects.initial().copy( + closeComposerWithMessageSending = Effect.of(Unit) + ) + + val viewModel = viewModel() + + // When + Then + recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.TO) + + viewModel.composerStates.test { + sharedFlowExpiration.emit(expectedMessageExpiration) + sharedFlowPassword.emit(expectedMessagePassword) + viewModel.subjectTextField.edit { append(draftFields.subject.value) } + viewModel.bodyFieldText.edit { append(draftFields.body.value) } + + viewModel.submitAction(ComposerAction2.SendMessage) + advanceUntilIdle() + + verifyStates(main = firstMainState, actualStates = awaitItem()) + verifyStates(main = firstMainState, accessories = firstAccessoriesState, actualStates = awaitItem()) + verifyStates(main = firstMainState, accessories = finalAccessoriesState, actualStates = awaitItem()) + verifyStates(main = finalMainState, accessories = finalAccessoriesState, actualStates = awaitItem()) + verifyStates( + main = finalMainState, + accessories = finalAccessoriesState, + effects = finalEffectsState, + actualStates = awaitItem() + ) + } + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(draftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = false)) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = true)) + composerStateReducer.reduceNewState(any(), AccessoriesEvent.OnExpirationChanged(expectedMessageExpiration)) + composerStateReducer.reduceNewState(any(), AccessoriesEvent.OnPasswordChanged(expectedMessagePassword)) + composerStateReducer.reduceNewState(any(), MainEvent.CoreLoadingToggled) + composerStateReducer.reduceNewState(any(), EffectsEvent.SendEvent.OnSendMessage) + } + + coVerify(exactly = 1) { + draftFacade.stopContinuousUpload() + messageSendingFacade.sendMessage(userId, messageId, draftFields) + } + } + + @Test + fun `should emit event when attachments are observed and trigger a draft save`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val sharedFlow = MutableSharedFlow>() + val expectedAttachment = MessageAttachmentSample.invoice + every { attachmentsFacade.observeMessageAttachments(userId, messageId) } returns sharedFlow + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val attachmentsState = ComposerState.Attachments.initial().copy( + AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = listOf( + AttachmentUiModel( + attachmentId = expectedAttachment.attachmentId.id, + fileName = "invoice", + extension = "pdf", + size = expectedAttachment.size, + mimeType = expectedAttachment.mimeType, + deletable = true + ) + ) + ) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + skipItems(1) + + sharedFlow.emit(listOf(expectedAttachment)) + advanceUntilIdle() + + verifyStates(main = expectedMainState, attachments = attachmentsState, actualStates = awaitItem()) + } + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(defaultDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = false)) + composerStateReducer.reduceNewState(any(), AttachmentsEvent.OnListChanged(listOf(expectedAttachment))) + } + + coVerify(exactly = 1) { + draftFacade.storeDraft(userId, messageId, emptyDraftFields(), DraftAction.Compose) + } + } + + @Test + fun `should emit event when sending errors are observed`() = runTest { + // Given + expectNewDraftReady(savedStateHandle) + + val sharedFlow = MutableSharedFlow() + val expectedError = "Some error" + every { messageSendingFacade.observeAndFormatSendingErrors(userId, messageId) } returns sharedFlow + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val expectedEffectsState = ComposerState.Effects.initial().copy( + sendingErrorEffect = Effect.of(TextUiModel(expectedError)) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + skipItems(1) + + sharedFlow.emit(expectedError) + advanceUntilIdle() + + verifyStates(main = expectedMainState, effects = expectedEffectsState, actualStates = awaitItem()) + } + + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(defaultDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = false)) + composerStateReducer.reduceNewState(any(), EffectsEvent.SendEvent.OnSendingError(expectedError)) + } + } + + private suspend fun TestScope.verifyDraftSave( + viewModel: ComposerViewModel2, + draftFields: DraftFields, + shouldSaveDraft: Boolean, + hasValidRecipients: Boolean = false, + draftAction: DraftAction, + withAction: () -> Unit + ) { + + val finalMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + loadingType = ComposerState.LoadingType.Save + ) + + val finalEffectsState = if (shouldSaveDraft) { + ComposerState.Effects.initial().copy(closeComposerWithDraftSaved = Effect.of(Unit)) + } else { + ComposerState.Effects.initial().copy(closeComposer = Effect.of(Unit)) + } + + viewModel.composerStates.test { + skipItems(1) + withAction() + + viewModel.submitAction(ComposerAction2.CloseComposer) + advanceUntilIdle() + + verifyStates(main = finalMainState, actualStates = awaitItem()) + verifyStates(main = finalMainState, effects = finalEffectsState, actualStates = awaitItem()) + } + + // Then + coVerifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(defaultDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), MainEvent.RecipientsChanged(areSubmittable = hasValidRecipients)) + composerStateReducer.reduceNewState(any(), MainEvent.CoreLoadingToggled) + composerStateReducer.reduceNewState( + any(), ComposerControlEvent.OnCloseRequest(hasDraftSaved = shouldSaveDraft) + ) + } + + if (shouldSaveDraft) { + coVerify(exactly = 1) { + draftFacade.storeDraft(userId, messageId, draftFields, draftAction) + draftFacade.forceUpload(userId, messageId) + } + } else { + coVerify(exactly = 0) { draftFacade.storeDraft(any(), any(), any(), any()) } + coVerify(exactly = 0) { draftFacade.forceUpload(any(), any()) } + } + } + + private fun emptyDraftFields() = DraftFields( + sender = defaultDraftFields.sender, + subject = Subject(""), + body = DraftBody(""), + recipientsTo = RecipientsTo(emptyList()), + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()), + originalHtmlQuote = null + ) + + private fun expectNewDraftReady(savedStateHandle: SavedStateHandle) { + expectStandaloneDraft(savedStateHandle) + every { draftFacade.provideNewDraftId() } returns messageId + coEvery { addressesFacade.getPrimarySenderEmail(userId) } returns defaultDraftFields.sender.right() + + coEvery { + draftFacade.injectAddressSignature(userId, DraftBody(""), defaultDraftFields.sender) + } returns DraftBody("").right() + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2SharedTestData.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2SharedTestData.kt new file mode 100644 index 0000000000..762d248144 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2SharedTestData.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import arrow.core.right +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.facade.AddressesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.DraftFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageContentFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageParticipantsFacade +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerStates +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsState +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Participant +import ch.protonmail.android.mailmessage.domain.model.Recipient +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.runs +import kotlinx.collections.immutable.toImmutableList +import me.proton.core.domain.entity.UserId +import me.proton.core.util.kotlin.serialize +import kotlin.test.assertEquals + +internal object ComposerViewModel2SharedTestData { + + val userId = UserId("user-id") + val messageId = MessageId("message-id") + val parentMessageId = MessageId("parent-message-id") + val recipientsTo = listOf(Recipient(address = "123@321.1", "nameTo")) + val recipientsCc = listOf(Recipient(address = "123@321.2", "nameCc")) + val recipientsBcc = listOf(Recipient(address = "123@321.3", "nameBcc")) + val originalHtmlQuote = OriginalHtmlQuote("original-html") + val styledHtml = StyledHtmlQuote("quoted-html") + + val defaultDraftFields = DraftFields( + sender = SenderEmail("sender@email.com"), + subject = Subject("Subject"), + body = DraftBody("Body"), + recipientsTo = RecipientsTo(recipientsTo), + recipientsCc = RecipientsCc(recipientsCc), + recipientsBcc = RecipientsBcc(recipientsBcc), + originalHtmlQuote = originalHtmlQuote + ) + + val defaultRecipientsState = RecipientsState( + toRecipients = recipientsTo.map { RecipientUiModel.Valid(it.address) }.toImmutableList(), + ccRecipients = recipientsCc.map { RecipientUiModel.Valid(it.address) }.toImmutableList(), + bccRecipients = recipientsBcc.map { RecipientUiModel.Valid(it.address) }.toImmutableList() + ) + + fun expectShareViaData(savedStateHandle: SavedStateHandle, rawData: String) { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns null + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns null + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns rawData + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns null + } + + fun expectExistingDraft(savedStateHandle: SavedStateHandle, rawMessageId: String) { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns rawMessageId + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns null + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns null + } + + fun expectRestoredState(savedStateHandle: SavedStateHandle) { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns messageId.id + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns null + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns true + } + + fun expectStandaloneDraft(savedStateHandle: SavedStateHandle) { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns null + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns null + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns null + } + + fun expectComposeToAddressDraft(savedStateHandle: SavedStateHandle, rawData: String) { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns null + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns rawData + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns null + } + + @Suppress("LongParameterList") + fun expectDraftAction( + draftFacade: DraftFacade, + addressesFacade: AddressesFacade, + messageContentFacade: MessageContentFacade, + savedStateHandle: SavedStateHandle, + draftAction: DraftAction, + parentMessage: MessageWithDecryptedBody, + draftFields: DraftFields = defaultDraftFields + ) { + every { draftFacade.provideNewDraftId() } returns messageId + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns null + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns draftAction.serialize() + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + every { savedStateHandle.get(ComposerScreen.HasSavedDraftKey) } returns null + + val validatedSender = ValidateSenderAddress.ValidationResult.Valid(defaultDraftFields.sender) + coEvery { + draftFacade.parentMessageToDraftFields(userId, parentMessageId, draftAction) + } returns Pair(parentMessage, draftFields) + + coEvery { + draftFacade.storeDraftWithParentAttachments( + userId = userId, + messageId = messageId, + parentMessage = parentMessage, + senderEmail = draftFields.sender, + draftAction = draftAction + ) + } returns Unit.right() + + coEvery { + addressesFacade.validateSenderAddress(userId, draftFields.sender) + } returns validatedSender.right() + + coEvery { + messageContentFacade.styleQuotedHtml(draftFields.originalHtmlQuote!!) + } returns styledHtml + + coEvery { draftFacade.storeDraft(userId, messageId, draftFields, draftAction) } returns Unit.right() + every { savedStateHandle[ComposerScreen.HasSavedDraftKey] = true } just runs + } + + fun expectParticipantsMapping(messageParticipantsFacade: MessageParticipantsFacade) { + val toRecipient = recipientsTo.first() + coEvery { + messageParticipantsFacade.mapToParticipant(RecipientUiModel.Valid(toRecipient.address)) + } returns Participant( + toRecipient.address, + toRecipient.name + ) + val ccRecipient = recipientsCc.first() + coEvery { + messageParticipantsFacade.mapToParticipant(RecipientUiModel.Valid(ccRecipient.address)) + } returns Participant( + ccRecipient.address, + ccRecipient.name + ) + val bccRecipient = recipientsBcc.first() + coEvery { + messageParticipantsFacade.mapToParticipant(RecipientUiModel.Valid(bccRecipient.address)) + } returns Participant( + bccRecipient.address, + bccRecipient.name + ) + } + + fun verifyStates( + main: ComposerState.Main = ComposerState.Main.initial(messageId), + attachments: ComposerState.Attachments = ComposerState.Attachments.initial(), + accessories: ComposerState.Accessories = ComposerState.Accessories.initial(), + effects: ComposerState.Effects = ComposerState.Effects.initial(), + actualStates: ComposerStates + ) { + assertEquals(main, actualStates.main) + assertEquals(attachments, actualStates.attachments) + assertEquals(accessories, actualStates.accessories) + assertEquals(effects, actualStates.effects) + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2Test.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2Test.kt new file mode 100644 index 0000000000..06e78f8eec --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModel2Test.kt @@ -0,0 +1,792 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import android.os.Build +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.model.IntentShareInfo +import ch.protonmail.android.mailcommon.domain.system.BuildVersionProvider +import ch.protonmail.android.mailcommon.domain.util.toUrlSafeBase64String +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress.ValidationFailure.CouldNotValidate +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.facade.AddressesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.AttachmentsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.DraftFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageAttributesFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageContentFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageParticipantsFacade +import ch.protonmail.android.mailcomposer.presentation.facade.MessageSendingFacade +import ch.protonmail.android.mailcomposer.presentation.model.ComposerState +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsState +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.model.operations.CompositeEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.EffectsEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.MainEvent +import ch.protonmail.android.mailcomposer.presentation.model.operations.MainEvent.RecipientsChanged +import ch.protonmail.android.mailcomposer.presentation.reducer.ComposerStateReducer +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.defaultDraftFields +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.defaultRecipientsState +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectComposeToAddressDraft +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectDraftAction +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectExistingDraft +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectParticipantsMapping +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectRestoredState +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectShareViaData +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.expectStandaloneDraft +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.messageId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.parentMessageId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.recipientsBcc +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.recipientsCc +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.recipientsTo +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.styledHtml +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.userId +import ch.protonmail.android.mailcomposer.presentation.viewmodel.ComposerViewModel2SharedTestData.verifyStates +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.Ordering +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verifySequence +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import me.proton.core.network.domain.NetworkManager +import me.proton.core.util.kotlin.serialize +import org.junit.Rule +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class ComposerViewModel2Test { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + private val draftFacade = mockk { + every { this@mockk.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) } just runs + } + private val attachmentsFacade = mockk() + private val addressesFacade = mockk() + private val messageAttributesFacade = mockk() + private val messageContentFacade = mockk() + private val messageParticipantsFacade = mockk { + every { this@mockk.observePrimaryUserId() } returns flowOf(userId) + } + private val messageSendingFacade = mockk() + + private val appInBackgroundState = mockk { + every { this@mockk.observe() } returns flowOf(false) + } + private val savedStateHandle = mockk() + private val networkManagerMock = mockk() + private val composerStateReducer = spyk() + private val recipientsStateManager = spyk() + private val shouldRestrictWebViewHeight = mockk { + every { this@mockk.invoke(null) } returns false + } + private val buildVersionProvider = mockk { + every { sdkInt() } returns Build.VERSION_CODES.S + } + + private fun viewModel(): ComposerViewModel2 = ComposerViewModel2( + draftFacade, + attachmentsFacade, + messageAttributesFacade, + messageContentFacade, + messageParticipantsFacade, + messageSendingFacade, + addressesFacade, + appInBackgroundState, + networkManagerMock, + savedStateHandle, + composerStateReducer, + testDispatcher, + recipientsStateManager, + shouldRestrictWebViewHeight, + buildVersionProvider + ) + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `should close composer when restored from handle (process death)`() = runTest { + // Given + expectRestoredState(savedStateHandle) + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + loadingType = ComposerState.LoadingType.Initial + ) + + val expectedEffectsState = ComposerState.Effects.initial().copy( + closeComposer = Effect.of(Unit) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + verifyStates(main = expectedMainState, effects = expectedEffectsState, actualStates = awaitItem()) + } + + // Then + verifySequence { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), EffectsEvent.ComposerControlEvent.OnComposerRestored) + } + confirmVerified(composerStateReducer) + } + + @Test + fun `should prefill composer with existing draft id (remote data)`() = runTest { + // Given + expectDraftLoaded(fromRemote = true, draftAction = DraftAction.Compose) + + val expectedEvent = CompositeEvent.DraftContentReady( + senderEmail = defaultDraftFields.sender.value, + isDataRefreshed = true, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(defaultDraftFields.sender), + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = false, + forceBodyFocus = false + ) + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + isSubmittable = true, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(main = expectedMainState, actualStates = awaitItem()) + } + + advanceUntilIdle() + + assertEquals(defaultDraftFields.body.value, viewModel.bodyFieldText.text.toString()) + assertEquals(defaultDraftFields.subject.value, viewModel.subjectTextField.text.toString()) + + coVerify(ordering = Ordering.SEQUENCE) { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + draftFacade.getDecryptedDraftFields(userId, messageId) + messageContentFacade.styleQuotedHtml(defaultDraftFields.originalHtmlQuote!!) + + composerStateReducer.reduceNewState(any(), expectedEvent) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + draftFacade.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = true)) + draftFacade.storeDraft(userId, messageId, defaultDraftFields, DraftAction.Compose) + } + + confirmVerified(composerStateReducer, draftFacade) + } + + @Test + fun `should prefill composer with existing draft id and emit warning if from local data only`() = runTest { + // Given + expectDraftLoaded(fromRemote = false, draftAction = DraftAction.Compose) + + val expectedEvent = CompositeEvent.DraftContentReady( + senderEmail = defaultDraftFields.sender.value, + isDataRefreshed = false, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(defaultDraftFields.sender), + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = false, + forceBodyFocus = false + ) + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + isSubmittable = true, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml) + ) + + val expectedEffectsState = ComposerState.Effects.initial().copy( + warning = Effect.of(TextUiModel(R.string.composer_warning_local_data_shown)) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(main = expectedMainState, effects = expectedEffectsState, actualStates = awaitItem()) + } + + advanceUntilIdle() + + assertEquals(defaultDraftFields.body.value, viewModel.bodyFieldText.text.toString()) + assertEquals(defaultDraftFields.subject.value, viewModel.subjectTextField.text.toString()) + + coVerify(ordering = Ordering.SEQUENCE) { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + draftFacade.getDecryptedDraftFields(userId, messageId) + messageContentFacade.styleQuotedHtml(defaultDraftFields.originalHtmlQuote!!) + + composerStateReducer.reduceNewState(any(), expectedEvent) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + draftFacade.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = true)) + draftFacade.storeDraft(userId, messageId, defaultDraftFields, DraftAction.Compose) + } + + confirmVerified(composerStateReducer, draftFacade) + } + + @Test + fun `should prefill composer for a given draft action (reply)`() = runTest { + // Given + val draftAction = DraftAction.Reply(parentMessageId) + val parentMessage = mockk() + val draftFields = defaultDraftFields.copy( + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()) + ) + + setupDraftAction(draftAction, parentMessage, draftFields) + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val expectedEvent = CompositeEvent.DraftContentReady( + senderEmail = draftFields.sender.value, + isDataRefreshed = true, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(draftFields.sender), + quotedHtmlContent = QuotedHtmlContent(draftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = false, + forceBodyFocus = true + ) + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = true, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(draftFields.originalHtmlQuote!!, styledHtml) + ) + + val expectedEffectState = ComposerState.Effects.initial().copy( + focusTextBody = Effect.of(Unit) + ) + + val expectedRecipientTo = listOf( + draftFields.recipientsTo.value.first().let { RecipientUiModel.Valid(it.address) } + ).toImmutableList() + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + advanceUntilIdle() + verifyStates(main = expectedMainState, effects = expectedEffectState, actualStates = awaitItem()) + } + + coVerify { + draftFacade.provideNewDraftId() + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + draftFacade.parentMessageToDraftFields(userId, parentMessageId, draftAction) + messageContentFacade.styleQuotedHtml(draftFields.originalHtmlQuote!!) + + composerStateReducer.reduceNewState(any(), expectedEvent) + draftFacade.storeDraftWithParentAttachments( + userId, messageId, parentMessage, draftFields.sender, draftAction + ) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) + + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = true)) + draftFacade.storeDraft(userId, messageId, draftFields, draftAction) + } + + confirmVerified(draftFacade, composerStateReducer) + + assertEquals(draftFields.body.value, viewModel.bodyFieldText.text.toString()) + assertEquals(draftFields.subject.value, viewModel.subjectTextField.text.toString()) + assertEquals( + recipientsStateManager.recipients.value, + RecipientsState.Empty.copy(toRecipients = expectedRecipientTo) + ) + } + + @Test + fun `should prefill composer for a given draft action (reply all)`() = runTest { + // Given + val draftAction = DraftAction.ReplyAll(parentMessageId) + val parentMessage = mockk() + + setupDraftAction(draftAction, parentMessage) + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val expectedEvent = CompositeEvent.DraftContentReady( + senderEmail = defaultDraftFields.sender.value, + isDataRefreshed = true, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(defaultDraftFields.sender), + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = false, + forceBodyFocus = true + ) + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + isSubmittable = true, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml) + ) + + val expectedEffectState = ComposerState.Effects.initial().copy( + focusTextBody = Effect.of(Unit) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + advanceUntilIdle() + verifyStates(main = expectedMainState, effects = expectedEffectState, actualStates = awaitItem()) + } + + coVerify(ordering = Ordering.SEQUENCE) { + draftFacade.provideNewDraftId() + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + draftFacade.parentMessageToDraftFields(userId, parentMessageId, draftAction) + messageContentFacade.styleQuotedHtml(defaultDraftFields.originalHtmlQuote!!) + + composerStateReducer.reduceNewState(any(), expectedEvent) + draftFacade.storeDraftWithParentAttachments( + userId, messageId, parentMessage, defaultDraftFields.sender, draftAction + ) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) + + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = true)) + draftFacade.storeDraft(userId, messageId, defaultDraftFields, draftAction) + } + + assertEquals(defaultDraftFields.body.value, viewModel.bodyFieldText.text.toString()) + assertEquals(defaultDraftFields.subject.value, viewModel.subjectTextField.text.toString()) + assertEquals(recipientsStateManager.recipients.value, defaultRecipientsState) + } + + @Test + fun `should prefill composer for a given draft action (forward)`() = runTest { + // Given + val draftAction = DraftAction.Forward(parentMessageId) + val parentMessage = mockk() + + val draftFields = defaultDraftFields.copy( + recipientsTo = RecipientsTo(emptyList()), + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()) + ) + + setupDraftAction(draftAction, parentMessage, draftFields) + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = false, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(draftFields.originalHtmlQuote!!, styledHtml) + ) + + val expectedEffectState = ComposerState.Effects.initial().copy( + focusTextBody = Effect.empty() + ) + + val expectedEvent = CompositeEvent.DraftContentReady( + senderEmail = defaultDraftFields.sender.value, + isDataRefreshed = true, + senderValidationResult = ValidateSenderAddress.ValidationResult.Valid(defaultDraftFields.sender), + quotedHtmlContent = QuotedHtmlContent(defaultDraftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = false, + forceBodyFocus = false + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + advanceUntilIdle() + verifyStates(main = expectedMainState, effects = expectedEffectState, actualStates = awaitItem()) + } + + coVerify(ordering = Ordering.SEQUENCE) { + draftFacade.provideNewDraftId() + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + draftFacade.parentMessageToDraftFields(userId, parentMessageId, draftAction) + messageContentFacade.styleQuotedHtml(draftFields.originalHtmlQuote!!) + + composerStateReducer.reduceNewState(any(), expectedEvent) + draftFacade.storeDraftWithParentAttachments( + userId, messageId, parentMessage, draftFields.sender, draftAction + ) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) + + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = false)) + draftFacade.storeDraft(userId, messageId, draftFields, draftAction) + } + + assertEquals(draftFields.body.value, viewModel.bodyFieldText.text.toString()) + assertEquals(draftFields.subject.value, viewModel.subjectTextField.text.toString()) + assertEquals(recipientsStateManager.recipients.value, RecipientsState.Empty) + } + + @Test + fun `should prefill composer and limit web view height when the FF is ON and the Android version is 9`() = runTest { + // Given + val draftAction = DraftAction.Forward(parentMessageId) + val parentMessage = mockk() + + val draftFields = defaultDraftFields.copy( + recipientsTo = RecipientsTo(emptyList()), + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()) + ) + + setupDraftAction(draftAction, parentMessage, draftFields) + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + every { shouldRestrictWebViewHeight(null) } returns true + every { buildVersionProvider.sdkInt() } returns Build.VERSION_CODES.P + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(draftFields.sender.value), + isSubmittable = false, + loadingType = ComposerState.LoadingType.None, + quotedHtmlContent = QuotedHtmlContent(draftFields.originalHtmlQuote!!, styledHtml), + shouldRestrictWebViewHeight = true + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + advanceUntilIdle() + verifyStates(main = expectedMainState, actualStates = awaitItem()) + } + } + + @Test + fun `should notify draft loading failure on existing draft action (parent message loading, exit)`() = runTest { + // Given + val draftAction = DraftAction.Forward(parentMessageId) + setupDraftAction(draftAction, mockk(), defaultDraftFields) + + relaxMessageObservers() + coEvery { draftFacade.parentMessageToDraftFields(any(), any(), any()) } returns null + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val expectedEffectState = ComposerState.Effects.initial().copy( + exitError = Effect.of(TextUiModel(R.string.composer_error_loading_parent_message)) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(effects = expectedEffectState, actualStates = awaitItem()) + } + } + + @Test + fun `should notify and exit composer on sender validation unrecoverable failure`() = runTest { + // Given + val draftAction = DraftAction.Forward(parentMessageId) + setupDraftAction(draftAction, mockk(), defaultDraftFields) + relaxMessageObservers() + + coEvery { + draftFacade.parentMessageToDraftFields(any(), any(), any()) + } returns Pair(mockk(), defaultDraftFields) + + coEvery { addressesFacade.validateSenderAddress(any(), any()) } returns CouldNotValidate.left() + coEvery { draftFacade.startContinuousUpload(userId, messageId, draftAction, any()) } just runs + + val expectedEffectState = ComposerState.Effects.initial().copy( + exitError = Effect.of(TextUiModel(R.string.composer_error_invalid_sender)) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(effects = expectedEffectState, actualStates = awaitItem()) + } + } + + @Test + fun `should notify draft loading failure`() = runTest { + // Given + expectExistingDraft(savedStateHandle, messageId.id) + relaxMessageObservers() + coEvery { draftFacade.getDecryptedDraftFields(userId, messageId) } returns DataError.Local.NoDataCached.left() + + val expectedEffectState = ComposerState.Effects.initial().copy( + error = Effect.of(TextUiModel(R.string.composer_error_loading_draft)) + ) + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(effects = expectedEffectState, actualStates = awaitItem()) + } + + assertTrue { viewModel.bodyFieldText.text.isEmpty() } + assertTrue { viewModel.subjectTextField.text.isEmpty() } + + coVerify(ordering = Ordering.SEQUENCE) { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), EffectsEvent.DraftEvent.OnDraftLoadingFailed) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = false)) + } + + confirmVerified(composerStateReducer) + } + + @Test + fun `should prefill with the share via data`() = runTest { + // Given + every { draftFacade.provideNewDraftId() } returns messageId + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + + val draftBody = DraftBody("Body message") + val draftBodyWithSignature = DraftBody("Body message + signature") + val intentShareData = IntentShareInfo.Empty.copy( + attachmentUris = emptyList(), + emailSubject = "Subject".toUrlSafeBase64String(), + emailRecipientTo = recipientsTo.map { it.address.toUrlSafeBase64String() }, + emailRecipientCc = recipientsCc.map { it.address.toUrlSafeBase64String() }, + emailRecipientBcc = recipientsBcc.map { it.address.toUrlSafeBase64String() }, + emailBody = draftBody.value.toUrlSafeBase64String(), + encoded = true + ) + + val expectedState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value), + isSubmittable = true + ) + + expectShareViaData(savedStateHandle, DraftAction.PrefillForShare(intentShareData).serialize()) + every { draftFacade.provideNewDraftId() } returns messageId + coEvery { addressesFacade.getPrimarySenderEmail(userId) } returns defaultDraftFields.sender.right() + coEvery { + draftFacade.injectAddressSignature(userId, draftBody, defaultDraftFields.sender) + } returns draftBodyWithSignature.right() + + coEvery { draftFacade.storeDraft(userId, messageId, any(), any()) } returns Unit.right() + every { savedStateHandle[ComposerScreen.HasSavedDraftKey] = true } just runs + + val viewModel = viewModel() + + // When + Then + viewModel.composerStates.test { + verifyStates(main = expectedState, actualStates = awaitItem()) + } + + assertEquals(draftBodyWithSignature.value, viewModel.bodyFieldText.text.toString()) + assertEquals(defaultDraftFields.subject.value, viewModel.subjectTextField.text.toString()) + assertEquals(recipientsStateManager.recipients.value, defaultRecipientsState) + } + + @Test + fun `should setup a standalone draft`() = runTest { + // Given + expectStandaloneDraft(savedStateHandle) + every { draftFacade.provideNewDraftId() } returns messageId + coEvery { addressesFacade.getPrimarySenderEmail(userId) } returns defaultDraftFields.sender.right() + relaxMessageObservers() + + val expectedBodyWithSignature = DraftBody("Signature") + coEvery { + draftFacade.injectAddressSignature(userId, DraftBody(""), defaultDraftFields.sender) + } returns expectedBodyWithSignature.right() + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(defaultDraftFields.sender.value) + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + verifyStates(main = expectedMainState, actualStates = awaitItem()) + } + + // Then + coVerify { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(defaultDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = false)) + } + + assertEquals(expectedBodyWithSignature.value, viewModel.bodyFieldText.text.toString()) + assertTrue(viewModel.subjectTextField.text.isEmpty()) + } + + @Test + fun `should setup a standalone draft when composing to address`() = runTest { + // Given + val action = DraftAction.ComposeToAddresses(listOf("123@321.1")) + val initialDraftFields = DraftFields( + sender = SenderEmail("sender@email.com"), + subject = Subject(""), + body = DraftBody("Signature"), + recipientsTo = RecipientsTo(recipientsTo), + recipientsCc = RecipientsCc(emptyList()), + recipientsBcc = RecipientsBcc(emptyList()), + originalHtmlQuote = null + ) + expectParticipantsMapping(messageParticipantsFacade) + expectComposeToAddressDraft( + savedStateHandle, + Json + .encodeToString(DraftAction.serializer(), action) + ) + every { savedStateHandle[ComposerScreen.HasSavedDraftKey] = true } just runs + coEvery { draftFacade.storeDraft(userId, messageId, initialDraftFields, action) } returns Unit.right() + every { draftFacade.provideNewDraftId() } returns messageId + coEvery { addressesFacade.getPrimarySenderEmail(userId) } returns initialDraftFields.sender.right() + every { draftFacade.startContinuousUpload(userId, messageId, action, any()) } just runs + relaxMessageObservers() + + val expectedBodyWithSignature = DraftBody("Signature") + coEvery { + draftFacade.injectAddressSignature(userId, DraftBody(""), initialDraftFields.sender) + } returns expectedBodyWithSignature.right() + + val expectedMainState = ComposerState.Main.initial(messageId).copy( + senderUiModel = SenderUiModel(initialDraftFields.sender.value), + isSubmittable = true + ) + + val viewModel = viewModel() + + // When + viewModel.composerStates.test { + verifyStates(main = expectedMainState, actualStates = awaitItem()) + } + + // Then + coVerify { + composerStateReducer.reduceNewState(any(), MainEvent.InitialLoadingToggled) + composerStateReducer.reduceNewState(any(), MainEvent.SenderChanged(initialDraftFields.sender)) + composerStateReducer.reduceNewState(any(), MainEvent.LoadingDismissed) + composerStateReducer.reduceNewState(any(), RecipientsChanged(areSubmittable = true)) + } + + assertEquals(expectedBodyWithSignature.value, viewModel.bodyFieldText.text.toString()) + assertTrue(viewModel.subjectTextField.text.isEmpty()) + } + + private fun setupDraftAction( + draftAction: DraftAction, + parentMessage: MessageWithDecryptedBody, + draftFields: DraftFields = defaultDraftFields + ) { + expectDraftAction( + draftFacade, + addressesFacade, + messageContentFacade, + savedStateHandle, + draftAction, + parentMessage, + draftFields + ) + } + + private fun expectDraftLoaded(fromRemote: Boolean, draftAction: DraftAction) { + expectExistingDraft(savedStateHandle, messageId.id) + relaxMessageObservers() + expectParticipantsMapping(messageParticipantsFacade) + + val expectedDraftFields = if (fromRemote) { + DecryptedDraftFields.Remote(defaultDraftFields) + } else { + DecryptedDraftFields.Local(defaultDraftFields) + } + + coEvery { draftFacade.getDecryptedDraftFields(userId, messageId) } returns expectedDraftFields.right() + coEvery { attachmentsFacade.storeExternalAttachments(userId, messageId) } just runs + coEvery { + messageContentFacade.styleQuotedHtml(defaultDraftFields.originalHtmlQuote!!) + } returns styledHtml + coEvery { draftFacade.storeDraft(userId, messageId, defaultDraftFields, draftAction) } returns Unit.right() + every { savedStateHandle[ComposerScreen.HasSavedDraftKey] = true } just runs + } + + private fun relaxMessageObservers() { + every { attachmentsFacade.observeMessageAttachments(userId, messageId) } returns flowOf() + coEvery { messageAttributesFacade.observeMessagePassword(userId, messageId) } returns flowOf() + coEvery { messageAttributesFacade.observeMessageExpiration(userId, messageId) } returns flowOf() + coEvery { messageSendingFacade.observeAndFormatSendingErrors(userId, messageId) } returns flowOf() + every { attachmentsFacade.observeMessageAttachments(userId, messageId) } returns flowOf() + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModelTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModelTest.kt new file mode 100644 index 0000000000..2c2c461179 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/ComposerViewModelTest.kt @@ -0,0 +1,3092 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.AppInBackgroundState +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.sample.LabelIdSample +import ch.protonmail.android.mailcommon.domain.sample.UserAddressSample +import ch.protonmail.android.mailcommon.domain.sample.UserIdSample +import ch.protonmail.android.mailcommon.domain.usecase.GetPrimaryAddress +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcommon.presentation.model.TextUiModel +import ch.protonmail.android.mailcommon.presentation.usecase.GetInitials +import ch.protonmail.android.mailcomposer.domain.model.DecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.model.DraftBody +import ch.protonmail.android.mailcomposer.domain.model.DraftFields +import ch.protonmail.android.mailcomposer.domain.model.MessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.MessageWithDecryptedBody +import ch.protonmail.android.mailcomposer.domain.model.OriginalHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.QuotedHtmlContent +import ch.protonmail.android.mailcomposer.domain.model.RecipientsBcc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsCc +import ch.protonmail.android.mailcomposer.domain.model.RecipientsTo +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.model.StyledHtmlQuote +import ch.protonmail.android.mailcomposer.domain.model.Subject +import ch.protonmail.android.mailcomposer.domain.usecase.AttachmentReEncryptionError +import ch.protonmail.android.mailcomposer.domain.usecase.ClearMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAllAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteAttachment +import ch.protonmail.android.mailcomposer.domain.usecase.DraftUploader +import ch.protonmail.android.mailcomposer.domain.usecase.GetComposerSenderAddresses +import ch.protonmail.android.mailcomposer.domain.usecase.GetDecryptedDraftFields +import ch.protonmail.android.mailcomposer.domain.usecase.GetExternalRecipients +import ch.protonmail.android.mailcomposer.domain.usecase.GetLocalMessageDecrypted +import ch.protonmail.android.mailcomposer.domain.usecase.IsValidEmailAddress +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessageSendingError +import ch.protonmail.android.mailcomposer.domain.usecase.ProvideNewDraftId +import ch.protonmail.android.mailcomposer.domain.usecase.ReEncryptAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessageExpirationTime +import ch.protonmail.android.mailcomposer.domain.usecase.SendMessage +import ch.protonmail.android.mailcomposer.domain.usecase.StoreAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAllFields +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithAttachmentError +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithBody +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithBodyError +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithParentAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithRecipients +import ch.protonmail.android.mailcomposer.domain.usecase.StoreDraftWithSubject +import ch.protonmail.android.mailcomposer.domain.usecase.StoreExternalAttachments +import ch.protonmail.android.mailcomposer.domain.usecase.ValidateSenderAddress +import ch.protonmail.android.mailcomposer.presentation.R +import ch.protonmail.android.mailcomposer.presentation.mapper.ParticipantMapper +import ch.protonmail.android.mailcomposer.presentation.model.ComposerAction +import ch.protonmail.android.mailcomposer.presentation.model.ComposerDraftState +import ch.protonmail.android.mailcomposer.presentation.model.ComposerFields +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.SenderUiModel +import ch.protonmail.android.mailcomposer.presentation.reducer.ComposerReducer +import ch.protonmail.android.mailcomposer.presentation.ui.ComposerScreen +import ch.protonmail.android.mailcomposer.presentation.usecase.ConvertHtmlToPlainText +import ch.protonmail.android.mailcomposer.presentation.usecase.FormatMessageSendingError +import ch.protonmail.android.mailcomposer.presentation.usecase.InjectAddressSignature +import ch.protonmail.android.mailcomposer.presentation.usecase.ParentMessageToDraftFields +import ch.protonmail.android.mailcomposer.presentation.usecase.SortContactsForSuggestions +import ch.protonmail.android.mailcomposer.presentation.usecase.StyleQuotedHtml +import ch.protonmail.android.mailcontact.domain.DeviceContactsSuggestionsPrompt +import ch.protonmail.android.mailcontact.domain.model.ContactGroup +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.mailcontact.domain.usecase.GetContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchContactGroups +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import ch.protonmail.android.mailcontact.domain.usecase.featureflags.IsDeviceContactsSuggestionsEnabled +import ch.protonmail.android.mailmessage.domain.model.AttachmentId +import ch.protonmail.android.mailmessage.domain.model.DraftAction +import ch.protonmail.android.mailmessage.domain.model.MessageId +import ch.protonmail.android.mailmessage.domain.model.Recipient +import ch.protonmail.android.mailmessage.domain.model.SendingError +import ch.protonmail.android.mailmessage.domain.sample.MessageAttachmentSample +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.mailmessage.domain.sample.MessageWithBodySample +import ch.protonmail.android.mailmessage.domain.sample.RecipientSample +import ch.protonmail.android.mailmessage.domain.usecase.ShouldRestrictWebViewHeight +import ch.protonmail.android.mailmessage.presentation.mapper.AttachmentUiModelMapper +import ch.protonmail.android.mailmessage.presentation.model.AttachmentGroupUiModel +import ch.protonmail.android.mailmessage.presentation.model.NO_ATTACHMENT_LIMIT +import ch.protonmail.android.mailmessage.presentation.sample.AttachmentUiModelSample +import ch.protonmail.android.test.idlingresources.ComposerIdlingResource +import ch.protonmail.android.test.utils.rule.LoggingTestRule +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import ch.protonmail.android.testdata.contact.ContactEmailSample +import ch.protonmail.android.testdata.contact.ContactSample +import ch.protonmail.android.testdata.contact.ContactTestData +import ch.protonmail.android.testdata.message.DecryptedMessageBodyTestData +import io.mockk.Called +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.entity.UserId +import me.proton.core.network.domain.NetworkManager +import me.proton.core.user.domain.entity.UserAddress +import me.proton.core.util.kotlin.serialize +import org.junit.Assert.assertNull +import org.junit.Rule +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +class ComposerViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + @get:Rule + val loggingTestRule = LoggingTestRule() + + private val storeAttachments = mockk() + private val storeDraftWithAllFields = mockk() + private val storeDraftWithBodyMock = mockk() + private val storeDraftWithSubjectMock = mockk { + coEvery { this@mockk.invoke(any(), any(), any(), any()) } returns Unit.right() + } + private val storeDraftWithRecipientsMock = mockk() + private val storeExternalAttachmentStates = mockk() + private val sendMessageMock = mockk() + private val networkManagerMock = mockk() + private val getContactsMock = mockk() + private val searchContactsMock = mockk() + private val searchDeviceContactsMock = mockk() + private val deviceContactsSuggestionsPromptMock = mockk { + coEvery { this@mockk.getPromptEnabled() } returns true + coEvery { this@mockk.setPromptDisabled() } just Runs + } + private val isDeviceContactsSuggestionsEnabledMock = mockk { + every { this@mockk.invoke() } returns false + } + private val searchContactGroupsMock = mockk() + private val participantMapperMock = mockk() + private val observePrimaryUserIdMock = mockk() + private val composerIdlingResource = spyk() + private val isValidEmailAddressMock = mockk() + private val getPrimaryAddressMock = mockk() + private val provideNewDraftIdMock = mockk() + private val draftUploaderMock = mockk() + private val getComposerSenderAddresses = mockk { + coEvery { this@mockk.invoke() } returns GetComposerSenderAddresses.Error.UpgradeToChangeSender.left() + } + private val savedStateHandle = mockk() + private val getDecryptedDraftFields = mockk() + private val styleQuotedHtml = mockk() + private val getLocalMessageDecrypted = mockk() + private val injectAddressSignature = mockk() + private val parentMessageToDraftFields = mockk() + private val storeDraftWithParentAttachments = mockk() + private val deleteAttachment = mockk() + private val deleteAllAttachments = mockk() + private val observeMessageAttachments = mockk() + private val observeMessageSendingError = mockk() + private val clearMessageSendingError = mockk() + private val formatMessageSendingError = mockk() + private val reEncryptAttachments = mockk() + private val appInBackgroundStateFlow = MutableStateFlow(false) + private val appInBackgroundState = mockk { + every { observe() } returns appInBackgroundStateFlow + } + private val observeMessagePassword = mockk() + private val validateSenderAddress = mockk() + private val saveMessageExpirationTime = mockk() + private val observeMessageExpirationTime = mockk() + private val getExternalRecipients = mockk() + private val convertHtmlToPlainText = mockk() + + private val getInitials = mockk { + every { this@mockk(any()) } returns BaseInitials + } + private val attachmentUiModelMapper = AttachmentUiModelMapper() + private val sortContactsForSuggestions = SortContactsForSuggestions(getInitials, testDispatcher) + private val shouldRestrictWebViewHeight = mockk { + every { this@mockk.invoke(null) } returns false + } + private val reducer = ComposerReducer(attachmentUiModelMapper, shouldRestrictWebViewHeight) + + private val viewModel by lazy { + ComposerViewModel( + appInBackgroundState, + storeAttachments, + storeDraftWithBodyMock, + storeDraftWithSubjectMock, + storeDraftWithAllFields, + storeDraftWithRecipientsMock, + storeExternalAttachmentStates, + getContactsMock, + searchContactsMock, + searchDeviceContactsMock, + deviceContactsSuggestionsPromptMock, + searchContactGroupsMock, + sortContactsForSuggestions, + participantMapperMock, + reducer, + isValidEmailAddressMock, + getPrimaryAddressMock, + getComposerSenderAddresses, + composerIdlingResource, + draftUploaderMock, + observeMessageAttachments, + observeMessageSendingError, + clearMessageSendingError, + formatMessageSendingError, + sendMessageMock, + networkManagerMock, + getLocalMessageDecrypted, + injectAddressSignature, + parentMessageToDraftFields, + styleQuotedHtml, + storeDraftWithParentAttachments, + deleteAttachment, + deleteAllAttachments, + reEncryptAttachments, + observeMessagePassword, + validateSenderAddress, + saveMessageExpirationTime, + observeMessageExpirationTime, + getExternalRecipients, + convertHtmlToPlainText, + isDeviceContactsSuggestionsEnabledMock, + getDecryptedDraftFields, + savedStateHandle, + observePrimaryUserIdMock, + provideNewDraftIdMock, + testDispatcher + ) + } + + @Test + fun `should store attachments when attachments are added to the draft`() { + // Given + val uri = mockk() + val primaryAddress = UserAddressSample.PrimaryAddress + val expectedUserId = expectedUserId { UserIdSample.Primary } + val messageId = MessageIdSample.Invoice + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedDraftBody = DraftBody("I am plaintext") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + val decryptedDraftFields = DecryptedDraftFields.Remote(expectedFields) + expectedPrimaryAddress(expectedUserId) { primaryAddress } + expectInputDraftMessageId { messageId } + expectStoreAllDraftFieldsSucceeds(expectedUserId, messageId, expectedFields) + expectStoreAttachmentsSucceeds(expectedUserId, messageId, expectedSenderEmail, listOf(uri)) + expectDecryptedDraftDataSuccess(expectedUserId, messageId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, messageId) + expectObservedMessageAttachments(expectedUserId, messageId) + expectNoInputDraftAction() + expectStoreParentAttachmentSucceeds(expectedUserId, messageId) + expectObserveMessageSendingError(expectedUserId, messageId) + expectMessagePassword(expectedUserId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, messageId) + + // When + viewModel.submit(ComposerAction.AttachmentsAdded(listOf(uri))) + + // Then + coVerify { storeAttachments(expectedUserId, messageId, expectedSenderEmail, listOf(uri)) } + } + + @Test + fun `should store the draft body when the body changes`() { + // Given + val primaryAddress = UserAddressSample.PrimaryAddress + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedDraftBody = DraftBody(RawDraftBody) + val expectedQuotedDraftBody = null + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val action = ComposerAction.DraftBodyChanged(expectedDraftBody) + expectedPrimaryAddress(expectedUserId) { primaryAddress } + expectStoreDraftBodySucceeds( + expectedMessageId, + expectedDraftBody, + expectedQuotedDraftBody, + expectedSenderEmail, + expectedUserId + ) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithBodyMock( + expectedUserId, + expectedMessageId, + expectedDraftBody, + expectedQuotedDraftBody, + expectedSenderEmail + ) + } + } + + @Test + fun `should emit Effect for ReplaceDraftBody when sender changes`() = runTest { + // Given + val expectedDraftBody = DraftBody(RawDraftBody) + val expectedQuotedDraftBody = null + val originalSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SenderChanged(SenderUiModel(expectedSenderEmail.value)) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodySucceeds( + expectedMessageId, + expectedDraftBody, + expectedQuotedDraftBody, + expectedSenderEmail, + expectedUserId + ) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectReEncryptAttachmentSucceeds(expectedUserId, expectedMessageId, originalSenderEmail, expectedSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), originalSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate an existing draft body before changing sender + expectedViewModelInitialState( + messageId = expectedMessageId, + draftBody = expectedDraftBody, + quotedBody = expectedQuotedDraftBody + ) + + val expectedReplaceDraftBodyTextUiModel = TextUiModel(expectDraftBodyWithSignature().value) + + // When + viewModel.submit(action) + + // Then + assertEquals( + expectedReplaceDraftBodyTextUiModel, + viewModel.state.value.replaceDraftBody.consume() + ) + } + + @Test + fun `should store draft with sender and current draft body when sender changes`() = runTest { + // Given + val expectedDraftBody = DraftBody(RawDraftBody) + val expectedQuotedDraftBody = null + val previousSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SenderChanged(SenderUiModel(expectedSenderEmail.value)) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodySucceeds( + expectedMessageId, + expectedDraftBody, + expectedQuotedDraftBody, + expectedSenderEmail, + expectedUserId + ) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectReEncryptAttachmentSucceeds(expectedUserId, expectedMessageId, previousSenderEmail, expectedSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), previousSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate an existing draft body before changing sender + expectedViewModelInitialState(messageId = expectedMessageId, draftBody = expectedDraftBody) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithBodyMock( + expectedUserId, + expectedMessageId, + expectedDraftBody, + expectedQuotedDraftBody, + expectedSenderEmail + ) + } + } + + @Test + fun `should store draft subject when subject changes`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SubjectChanged(expectedSubject) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftSubjectSucceeds(expectedMessageId, expectedSenderEmail, expectedUserId, expectedSubject) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithSubjectMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + expectedSubject + ) + } + } + + @Test + fun `should store draft recipients TO when they change`() = runTest { + // Given + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.RecipientsToChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsSucceeds( + expectedMessageId, + expectedSenderEmail, + expectedUserId, + expectedTo = expectedRecipients + ) + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithRecipientsMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + to = expectedRecipients + ) + } + } + + @Test + fun `should store draft recipients CC when they change`() = runTest { + // Given + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.RecipientsCcChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsSucceeds( + expectedMessageId, + expectedSenderEmail, + expectedUserId, + expectedCc = expectedRecipients + ) + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithRecipientsMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + cc = expectedRecipients + ) + } + } + + @Test + fun `should store draft recipients BCC when they change`() = runTest { + // Given + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.RecipientsBccChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsSucceeds( + expectedMessageId, + expectedSenderEmail, + expectedUserId, + expectedBcc = expectedRecipients + ) + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + coVerify { + storeDraftWithRecipientsMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + bcc = expectedRecipients + ) + } + } + + @Test + fun `should perform search when ContactSuggestionTermChanged`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "proton" + val suggestionField = ContactSuggestionsField.BCC + val expectedContacts = listOf(ContactSample.Doe, ContactSample.John) + val expectedDeviceContacts = emptyList() + val expectedContactGroups = emptyList() + val action = ComposerAction.ContactSuggestionTermChanged(expectedSearchTerm, suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContacts) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + verify { + searchContactsMock(expectedUserId, expectedSearchTerm) + searchContactGroupsMock(expectedUserId, expectedSearchTerm) + } + } + + @Test + fun `should emit ContactSuggestionsDismissed when searchTerm is blank`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "" + val suggestionField = ContactSuggestionsField.BCC + + val expectedContacts = emptyList() + + val expectedDeviceContacts = emptyList() + + val expectedContactGroups = emptyList() + val action = ComposerAction.ContactSuggestionTermChanged(expectedSearchTerm, suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContacts) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectIsDeviceContactsSuggestionsEnabled(true) + + // When + viewModel.submit(action) + val actual = viewModel.state.value + + // Then + assertEquals( + emptyMap(), + actual.contactSuggestions + ) + assertEquals(mapOf(ContactSuggestionsField.BCC to false), actual.areContactSuggestionsExpanded) + } + + @Test + fun `should call DeviceContactsSuggestionsPrompt when DeviceContactsPromptDenied is emitted`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "" + + val expectedContacts = emptyList() + + val expectedDeviceContacts = emptyList() + + val expectedContactGroups = emptyList() + val action = ComposerAction.DeviceContactsPromptDenied + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContacts) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectIsDeviceContactsSuggestionsEnabled(true) + + // When + viewModel.submit(action) + + // Then + viewModel.state.test { + awaitItem() + + coVerify { deviceContactsSuggestionsPromptMock.setPromptDisabled() } + } + } + + @Test + fun `should emit UpdateContactSuggestions when contact suggestions are found`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "contact" + val suggestionField = ContactSuggestionsField.BCC + + val expectedContacts = listOf( + ContactSample.Doe.copy( + contactEmails = listOf( + ContactTestData.buildContactEmailWith( + name = "doe contact", + address = "address1@proton.ch" + ) + ) + ), + ContactSample.John.copy( + contactEmails = listOf( + ContactTestData.buildContactEmailWith( + name = "john contact", + address = "address2@proton.ch" + ) + ) + ) + ) + + val expectedDeviceContacts = emptyList() + + val expectedContactGroups = listOf( + ContactGroup( + UserIdSample.Primary, + LabelIdSample.LabelCoworkers, + "Coworkers contact group", + "#AABBCC", + listOf(ContactEmailSample.contactEmail1) + ) + ) + val action = ComposerAction.ContactSuggestionTermChanged(expectedSearchTerm, suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContacts) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectIsDeviceContactsSuggestionsEnabled(true) + + // When + viewModel.submit(action) + val actual = viewModel.state.value + + // Then + assertEquals( + mapOf( + ContactSuggestionsField.BCC to listOf( + ContactSuggestionUiModel.Contact( + name = expectedContacts[0].contactEmails.first().name, + initial = BaseInitials, + email = expectedContacts[0].contactEmails.first().email + ), + ContactSuggestionUiModel.Contact( + name = expectedContacts[1].contactEmails.first().name, + initial = BaseInitials, + email = expectedContacts[1].contactEmails.first().email + ), + ContactSuggestionUiModel.ContactGroup( + expectedContactGroups[0].name, + expectedContactGroups[0].members.map { it.email }, + expectedContactGroups[0].color + ) + ) + ), + actual.contactSuggestions + ) + assertEquals(mapOf(ContactSuggestionsField.BCC to true), actual.areContactSuggestionsExpanded) + } + + @Test + fun `should emit UpdateContactSuggestions when device contact suggestions are found`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "contact" + val suggestionField = ContactSuggestionsField.BCC + + val expectedContacts = emptyList() + val expectedContactGroups = emptyList() + val expectedDeviceContacts = listOf( + DeviceContact( + "device contact 1 name", + "device contact 1 email" + ), + DeviceContact( + "device contact 2 name", + "device contact 2 email" + ) + ) + val action = ComposerAction.ContactSuggestionTermChanged(expectedSearchTerm, suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContacts) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectIsDeviceContactsSuggestionsEnabled(true) + + // When + viewModel.submit(action) + val actual = viewModel.state.value + + // Then + assertEquals( + mapOf( + ContactSuggestionsField.BCC to listOf( + ContactSuggestionUiModel.Contact( + name = expectedDeviceContacts[0].name, + initial = BaseInitials, + email = expectedDeviceContacts[0].email + ), + ContactSuggestionUiModel.Contact( + name = expectedDeviceContacts[1].name, + initial = BaseInitials, + email = expectedDeviceContacts[1].email + ) + ) + ), + actual.contactSuggestions + ) + assertEquals(mapOf(ContactSuggestionsField.BCC to true), actual.areContactSuggestionsExpanded) + } + + @Test + fun `should emit UpdateContactSuggestions limiting results according to constant max value`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedSearchTerm = "contact" + val suggestionField = ContactSuggestionsField.BCC + + val expectedContactsExceedingLimit = (1..ComposerViewModel.Companion.maxContactAutocompletionCount + 1).map { + ContactSample.John.copy( + contactEmails = listOf( + ContactTestData.buildContactEmailWith( + name = "contact $it", + address = "address$it@proton.ch" + ) + ) + ) + } + val expectedDeviceContacts = emptyList() + val expectedContactGroups = emptyList() + val action = ComposerAction.ContactSuggestionTermChanged(expectedSearchTerm, suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectSearchContacts(expectedUserId, expectedSearchTerm, expectedContactsExceedingLimit) + expectSearchDeviceContacts(expectedSearchTerm, expectedDeviceContacts) + expectSearchContactGroups(expectedUserId, expectedSearchTerm, expectedContactGroups) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + advanceUntilIdle() + val actual = viewModel.state.value + + // Then + assertEquals( + ComposerViewModel.Companion.maxContactAutocompletionCount, + actual.contactSuggestions[ContactSuggestionsField.BCC]!!.size + ) + } + + @Test + fun `should dismiss contact suggestions when ContactSuggestionsDismissed is emitted`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val suggestionField = ContactSuggestionsField.BCC + + val action = ComposerAction.ContactSuggestionsDismissed(suggestionField) + + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + val actual = viewModel.state.value + + // Then + assertEquals(mapOf(ContactSuggestionsField.BCC to false), actual.areContactSuggestionsExpanded) + } + + @Test + fun `should store all draft fields and upload the draft when composer is closed`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("I am plaintext") + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectStoreAllDraftFieldsSucceeds(expectedUserId, expectedMessageId, expectedFields) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectUploadDraftSucceeds(expectedUserId, expectedMessageId) + expectStopContinuousDraftUploadSucceeds() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate the existence of all fields before closing the composer + expectedViewModelInitialState( + messageId = expectedMessageId, + senderEmail = expectedSenderEmail, + subject = expectedSubject, + draftBody = expectedDraftBody, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.OnCloseComposer) + + // Then + coVerifyOrder { + draftUploaderMock.stopContinuousUpload() + storeDraftWithAllFields(expectedUserId, expectedMessageId, expectedFields) + draftUploaderMock.upload(expectedUserId, expectedMessageId) + } + assertEquals(Effect.of(Unit), viewModel.state.value.closeComposerWithDraftSaved) + } + + @Test + fun `should store all draft fields and send message when send button is clicked`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("I am plaintext") + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectNetworkManagerIsConnected() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectSendMessageSucceds(expectedUserId, expectedMessageId, expectedFields) + expectStopContinuousDraftUploadSucceeds() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate the existence of all fields before closing the composer + expectedViewModelInitialState( + messageId = expectedMessageId, + senderEmail = expectedSenderEmail, + subject = expectedSubject, + draftBody = expectedDraftBody, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.OnSendMessage) + + // Then + coVerifyOrder { + draftUploaderMock.stopContinuousUpload() + sendMessageMock(expectedUserId, expectedMessageId, expectedFields) + } + assertEquals(Effect.of(Unit), viewModel.state.value.closeComposerWithMessageSending) + } + + @Test + fun `should store all draft fields and send message in offline when send button is clicked`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("I am plaintext") + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectNetworkManagerIsDisconnected() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectSendMessageSucceds(expectedUserId, expectedMessageId, expectedFields) + expectStopContinuousDraftUploadSucceeds() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate the existence of all fields before closing the composer + expectedViewModelInitialState( + messageId = expectedMessageId, + senderEmail = expectedSenderEmail, + subject = expectedSubject, + draftBody = expectedDraftBody, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.OnSendMessage) + + // Then + coVerifyOrder { + draftUploaderMock.stopContinuousUpload() + sendMessageMock(expectedUserId, expectedMessageId, expectedFields) + } + assertEquals(Effect.of(Unit), viewModel.state.value.closeComposerWithMessageSendingOffline) + } + + @Test + fun `should not store draft when all fields are empty and composer is closed`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectContacts() + + // When + viewModel.submit(ComposerAction.OnCloseComposer) + + // Then + coVerify { storeDraftWithAllFields wasNot Called } + coVerify(exactly = 0) { draftUploaderMock.upload(any(), any()) } + assertEquals(Effect.of(Unit), viewModel.state.value.closeComposer) + } + + @Test + fun `should not store draft when body contains only signature and composer is closed`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + val expectedDraftBody = expectDraftBodyWithSignature() + expectInjectAddressSignature(expectedUserId, expectedDraftBody, expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectContacts() + + // Change internal state of the View Model to simulate an existing draft body before closing composer + expectedViewModelInitialState( + messageId = expectedMessageId, + draftBody = expectedDraftBody + ) + + // When + viewModel.submit(ComposerAction.OnCloseComposer) + + // Then + coVerify { storeDraftWithAllFields wasNot Called } + coVerify(exactly = 0) { draftUploaderMock.upload(any(), any()) } + assertEquals(Effect.of(Unit), viewModel.state.value.closeComposer) + } + + @Test + fun `should store and upload draft when any field which requires user input is not empty and composer is closed`() = + runTest { + // Given + val expectedSubject = Subject("Added subject") + val expectedDraftBody = DraftBody("") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectStoreAllDraftFieldsSucceeds(expectedUserId, expectedMessageId, expectedFields) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStopContinuousDraftUploadSucceeds() + expectUploadDraftSucceeds(expectedUserId, expectedMessageId) + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // Change internal state of the View Model to simulate the + // existence of all fields before closing the composer + expectedViewModelInitialState( + expectedMessageId, + expectedSenderEmail, + expectedSubject, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.OnCloseComposer) + + // Then + coVerifyOrder { + draftUploaderMock.stopContinuousUpload() + storeDraftWithAllFields(expectedUserId, expectedMessageId, expectedFields) + draftUploaderMock.upload(expectedUserId, expectedMessageId) + } + } + + @Test + fun `emits state with primary sender address when available`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val primaryAddress = expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(SenderUiModel(primaryAddress.email), actual.fields.sender) + } + + @Test + fun `emits state with sender address error when not available`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedPrimaryAddressError(expectedUserId) { DataError.Local.NoDataCached } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(TextUiModel(R.string.composer_error_invalid_sender), actual.error.consume()) + } + + @Test + fun `emits state with user addresses when sender can be changed`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val addresses = listOf(UserAddressSample.PrimaryAddress, UserAddressSample.AliasAddress) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedGetComposerSenderAddresses { addresses } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(ComposerAction.ChangeSenderRequested) + + // Then + val currentState = viewModel.state.value + val expected = addresses.map { SenderUiModel(it.email) } + assertEquals(expected, currentState.senderAddresses) + } + + @Test + fun `emits state with upgrade plan to change sender when user cannot change sender`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedGetComposerSenderAddressesError { GetComposerSenderAddresses.Error.UpgradeToChangeSender } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(ComposerAction.ChangeSenderRequested) + + // Then + val currentState = viewModel.state.value + val expected = TextUiModel(R.string.composer_change_sender_paid_feature) + assertEquals(expected, currentState.premiumFeatureMessage.consume()) + } + + @Test + fun `emits state with error when cannot determine if user can change sender`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedGetComposerSenderAddressesError { GetComposerSenderAddresses.Error.FailedDeterminingUserSubscription } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(ComposerAction.ChangeSenderRequested) + + // Then + val currentState = viewModel.state.value + val expected = TextUiModel(R.string.composer_error_change_sender_failed_getting_subscription) + assertEquals(expected, currentState.error.consume()) + } + + @Test + fun `emits state with new sender address when sender changed`() = runTest { + // Given + val expectedDraftBody = DraftBody("") + val originalSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SenderChanged(SenderUiModel(expectedSenderEmail.value)) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodySucceeds(expectedMessageId, expectedDraftBody, null, expectedSenderEmail, expectedUserId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectReEncryptAttachmentSucceeds(expectedUserId, expectedMessageId, originalSenderEmail, expectedSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), originalSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(SenderUiModel(expectedSenderEmail.value), currentState.fields.sender) + coVerify(exactly = 1) { + reEncryptAttachments(expectedUserId, expectedMessageId, originalSenderEmail, expectedSenderEmail) + } + } + + @Test + fun `emits all attachment deleted when re-encryption of attachment failed`() = runTest { + // Given + val expectedDraftBody = DraftBody("") + val previousSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SenderChanged(SenderUiModel(expectedSenderEmail.value)) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodySucceeds(expectedMessageId, expectedDraftBody, null, expectedSenderEmail, expectedUserId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectReEncryptAttachmentFails(expectedUserId, expectedMessageId, previousSenderEmail, expectedSenderEmail) + expectDeleteAllAttachmentsSucceeds(expectedUserId, previousSenderEmail, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), previousSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(Effect.of(Unit), currentState.attachmentsReEncryptionFailed) + } + + @Test + fun `emits state with saving draft with new sender error when save draft with sender returns error`() = runTest { + // Given + val expectedDraftBody = DraftBody("") + val originalSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedSenderEmail = SenderEmail(UserAddressSample.AliasAddress.email) + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SenderChanged(SenderUiModel(expectedSenderEmail.value)) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodyFails(expectedMessageId, expectedDraftBody, null, expectedSenderEmail, expectedUserId) { + StoreDraftWithBodyError.DraftSaveError + } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), originalSenderEmail) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_sender_address), currentState.error.consume()) + loggingTestRule.assertErrorLogged( + "Store draft $expectedMessageId with new sender ${expectedSenderEmail.value} failed" + ) + } + + @Test + fun `emits state with saving draft body error when save draft body returns error`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("updated-draft") + val action = ComposerAction.DraftBodyChanged(expectedDraftBody) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodyFails(expectedMessageId, expectedDraftBody, null, expectedSenderEmail, expectedUserId) { + StoreDraftWithBodyError.DraftSaveError + } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_body), currentState.error.consume()) + } + + @Test + fun `emits state with saving draft subject error when save draft subject returns error`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.SubjectChanged(expectedSubject) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftSubjectFails(expectedMessageId, expectedSenderEmail, expectedUserId, expectedSubject) { + StoreDraftWithSubject.Error.DraftReadError + } + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_subject), currentState.error.consume()) + loggingTestRule.assertErrorLogged( + "Store draft $expectedMessageId with new subject $expectedSubject failed" + ) + } + + @Test + fun `emits state with saving draft subject error when save draft TO returns error`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val action = ComposerAction.RecipientsToChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsFails( + expectedMessageId, expectedSenderEmail, expectedUserId, + expectedTo = expectedRecipients, expectedCc = null, expectedBcc = null + ) { + StoreDraftWithRecipients.Error.DraftSaveError + } + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_recipients), currentState.error.consume()) + } + + @Test + fun `emits state with saving draft subject error when save draft CC returns error`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val action = ComposerAction.RecipientsCcChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsFails( + expectedMessageId, expectedSenderEmail, expectedUserId, + expectedTo = null, expectedCc = expectedRecipients, expectedBcc = null + ) { + StoreDraftWithRecipients.Error.DraftSaveError + } + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_recipients), currentState.error.consume()) + } + + @Test + fun `emits state with saving draft subject error when save draft BCC returns error`() = runTest { + // Given + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedRecipients = listOf( + Recipient("valid@email.com", "Valid Email", false) + ) + val recipientsUiModels = listOf( + RecipientUiModel.Valid("valid@email.com"), + RecipientUiModel.Invalid("invalid email") + ) + val action = ComposerAction.RecipientsBccChanged(recipientsUiModels) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftRecipientsFails( + expectedMessageId, expectedSenderEmail, expectedUserId, + expectedTo = null, expectedCc = null, expectedBcc = expectedRecipients + ) { + StoreDraftWithRecipients.Error.DraftSaveError + } + mockParticipantMapper() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + // When + viewModel.submit(action) + + // Then + val currentState = viewModel.state.value + assertEquals(TextUiModel(R.string.composer_error_store_draft_recipients), currentState.error.consume()) + } + + @Test + fun `emits state with loading draft content when draftId was given as input`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.RemoteDraft } + val decryptedDraftFields = DecryptedDraftFields.Remote(existingDraftFields) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + // Simulate a small delay in getDecryptedDraftFields to ensure the "loading" state was emitted + expectDecryptedDraftDataSuccess(expectedUserId, expectedDraftId, 100) { decryptedDraftFields } + expectStartDraftSync(UserIdSample.Primary, MessageIdSample.RemoteDraft) + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectNoInputDraftAction() + expectStoreParentAttachmentSucceeds(expectedUserId, expectedDraftId) + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + val actual = viewModel.state.value + + // Then + assertTrue(actual.isLoading) + coVerify { getDecryptedDraftFields(expectedUserId, expectedDraftId) } + } + + @Test + fun `emits state with remote draft fields to be prefilled when getting decrypted draft fields succeeds`() = + runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.RemoteDraft } + val expectedDraftFields = existingDraftFields + val decryptedDraftFields = DecryptedDraftFields.Remote(existingDraftFields) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectDecryptedDraftDataSuccess(expectedUserId, expectedDraftId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, expectedDraftId) + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectStoreParentAttachmentSucceeds(expectedUserId, expectedDraftId) + expectNoInputDraftAction() + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + val actual = viewModel.state.value + + // Then + val expectedComposerFields = ComposerFields( + expectedDraftId, + SenderUiModel(expectedDraftFields.sender.value), + expectedDraftFields.recipientsTo.value.map { RecipientUiModel.Valid(it.address) }, + emptyList(), + emptyList(), + expectedDraftFields.subject.value, + expectedDraftFields.body.value, + null + ) + assertEquals(expectedComposerFields, actual.fields) + coVerify { storeExternalAttachmentStates(expectedUserId, expectedDraftId) } + } + + @Test + fun `emits state with local draft fields to be prefilled when getting decrypted draft fields succeeds`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.RemoteDraft } + val expectedDraftFields = existingDraftFields + val decryptedDraftFields = DecryptedDraftFields.Local(existingDraftFields) + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectDecryptedDraftDataSuccess(expectedUserId, expectedDraftId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, expectedDraftId) + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectStoreParentAttachmentSucceeds(expectedUserId, expectedDraftId) + expectNoInputDraftAction() + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + val actual = viewModel.state.value + + // Then + val expectedComposerFields = ComposerFields( + expectedDraftId, + SenderUiModel(expectedDraftFields.sender.value), + expectedDraftFields.recipientsTo.value.map { RecipientUiModel.Valid(it.address) }, + emptyList(), + emptyList(), + expectedDraftFields.subject.value, + expectedDraftFields.body.value, + null + ) + assertEquals(expectedComposerFields, actual.fields) + coVerify { storeExternalAttachmentStates(expectedUserId, expectedDraftId) } + expectStoreDraftSubjectSucceeds( + expectedDraftId, expectedSenderEmail, + expectedUserId, expectedDraftFields.subject + ) + } + + @Test + fun `emits state with composer fields to be prefilled when getting parent message draft fields succeeds`() = + runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedParentId = MessageIdSample.Invoice + val expectedAction = expectInputDraftAction { DraftAction.Reply(expectedParentId) } + val expectedDecryptedParentBody = DecryptedMessageBodyTestData.htmlInvoice + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(expectedUserId, expectedDraftId, expectedAction) + expectNoInputDraftMessageId() + val expectedMessageDecrypted = expectGetMessageWithDecryptedBodySuccess(expectedUserId, expectedParentId) { + MessageWithDecryptedBody(MessageWithBodySample.Invoice, expectedDecryptedParentBody) + } + val expectedDraftFields = expectParentMessageToDraftFieldsSuccess( + expectedUserId, expectedMessageDecrypted, expectedAction + ) { draftFieldsWithQuotedBody } + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + val expectedStyledQuote = expectStyleQuotedHtml(expectedDraftFields.originalHtmlQuote) { + StyledHtmlQuote(" ${expectedDraftFields.originalHtmlQuote?.value} ") + } + expectStoreDraftWithParentAttachmentsSucceeds( + expectedUserId, + expectedDraftId, + expectedMessageDecrypted, + expectedDraftFields.sender, + expectedAction + ) + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectValidSenderAddress(expectedUserId, expectedDraftFields.sender) + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + val actual = viewModel.state.value + + // Then + val expectedComposerFields = ComposerFields( + expectedDraftId, + SenderUiModel(expectedDraftFields.sender.value), + expectedDraftFields.recipientsTo.value.map { RecipientUiModel.Valid(it.address) }, + emptyList(), + emptyList(), + expectedDraftFields.subject.value, + expectedDraftFields.body.value, + QuotedHtmlContent(expectedDraftFields.originalHtmlQuote!!, expectedStyledQuote) + ) + assertEquals(expectedComposerFields, actual.fields) + } + + @Test + fun `emits state with valid sender and notice effect when parent draft sender is invalid`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedParentId = MessageIdSample.Invoice + val expectedAction = expectInputDraftAction { DraftAction.Reply(expectedParentId) } + val expectedDecryptedParentBody = DecryptedMessageBodyTestData.htmlInvoice + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(expectedUserId, expectedDraftId, expectedAction) + expectNoInputDraftMessageId() + val expectedMessageDecrypted = expectGetMessageWithDecryptedBodySuccess(expectedUserId, expectedParentId) { + MessageWithDecryptedBody(MessageWithBodySample.Invoice, expectedDecryptedParentBody) + } + val expectedValidEmail = SenderEmail("valid-to-use-instead@proton.me") + val expectedDraftFields = expectParentMessageToDraftFieldsSuccess( + expectedUserId, expectedMessageDecrypted, expectedAction + ) { draftFieldsWithQuotedBody } + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectStyleQuotedHtml(expectedDraftFields.originalHtmlQuote) { + StyledHtmlQuote(" ${expectedDraftFields.originalHtmlQuote?.value} ") + } + expectStoreDraftWithParentAttachmentsSucceeds( + expectedUserId, + expectedDraftId, + expectedMessageDecrypted, + expectedValidEmail, + expectedAction + ) + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + expectInvalidSenderAddress( + expectedUserId, + expectedDraftFields.sender, + expectedValidEmail, + ValidateSenderAddress.ValidationError.PaidAddress + ) + expectReEncryptAttachmentSucceeds( + expectedUserId, + expectedDraftId, + expectedDraftFields.sender, + expectedValidEmail + ) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(SenderUiModel(expectedValidEmail.value), actual.fields.sender) + assertEquals( + Effect.of(TextUiModel(R.string.composer_sender_changed_pm_address_is_a_paid_feature)), + actual.senderChangedNotice + ) + coVerify { + reEncryptAttachments(expectedUserId, expectedDraftId, expectedDraftFields.sender, expectedValidEmail) + } + } + + @Test + fun `emits state with error loading parent data when getting parent message draft fields fails`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedParentId = MessageIdSample.Invoice + val expectedAction = expectInputDraftAction { DraftAction.Reply(expectedParentId) } + val draftId = expectedMessageId { MessageIdSample.EmptyDraft } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(expectedUserId, draftId, expectedAction) + expectNoInputDraftMessageId() + expectParentDraftDataError(expectedUserId, expectedParentId) { DataError.Local.DecryptionError } + expectObservedMessageAttachments(expectedUserId, draftId) + expectObserveMessageSendingError(expectedUserId, draftId) + expectMessagePassword(expectedUserId, draftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, draftId) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(TextUiModel(R.string.composer_error_loading_parent_message), actual.error.consume()) + } + + @Test + fun `emits state with error loading existing draft when getting decrypted draft fields fails`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.RemoteDraft } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(expectedUserId, expectedDraftId, DraftAction.Compose) + expectDecryptedDraftDataError(expectedUserId, expectedDraftId) { DataError.Local.NoDataCached } + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectNoInputDraftAction() + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(TextUiModel(R.string.composer_error_loading_draft), actual.error.consume()) + } + + @Test + fun `starts syncing draft for current messageId when composer is opened`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + + // When + val actual = viewModel.state.value + + // Then + assertEquals(messageId, actual.fields.draftId) + coVerify { draftUploaderMock.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) } + } + + @Test + fun `emits state with an effect to open the file picker when add attachments action is submitted`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.RemoteDraft } + val decryptedDraftFields = DecryptedDraftFields.Remote(existingDraftFields) + expectDecryptedDraftDataSuccess(expectedUserId, expectedDraftId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, expectedDraftId) + expectNoInputDraftAction() + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectStoreParentAttachmentSucceeds(expectedUserId, expectedDraftId) + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + viewModel.submit(ComposerAction.OnAddAttachments) + + // Then + val actual = viewModel.state.value + assertEquals(Unit, actual.openImagePicker.consume()) + } + + @Test + fun `emits state with updated attachments when the attachments change`() = runTest { + // Given + val expectedUserId = expectedUserId { UserIdSample.Primary } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val expectedDraftId = expectInputDraftMessageId { MessageIdSample.Invoice } + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedDraftBody = DraftBody("I am plaintext") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + val decryptedDraftFields = DecryptedDraftFields.Remote(expectedFields) + expectNoInputDraftAction() + expectStoreAllDraftFieldsSucceeds(expectedUserId, expectedDraftId, expectedFields) + expectDecryptedDraftDataSuccess(expectedUserId, expectedDraftId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, expectedDraftId) + expectObservedMessageAttachments(expectedUserId, expectedDraftId) + expectStoreParentAttachmentSucceeds(expectedUserId, expectedDraftId) + expectObserveMessageSendingError(expectedUserId, expectedDraftId) + expectMessagePassword(expectedUserId, expectedDraftId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedDraftId) + + // When + viewModel.state.test { + + // Then + val expected = AttachmentGroupUiModel( + limit = NO_ATTACHMENT_LIMIT, + attachments = listOf(AttachmentUiModelSample.deletableInvoice) + ) + val actual = awaitItem().attachments + assertEquals(expected, actual) + } + } + + @Test + fun `delete compose action triggers delete attachment use case`() = runTest { + // Given + val primaryAddress = UserAddressSample.PrimaryAddress + val expectedUserId = expectedUserId { UserIdSample.Primary } + val messageId = MessageIdSample.Invoice + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedDraftBody = DraftBody("I am plaintext") + val expectedAttachmentId = AttachmentId("attachment_id") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + val decryptedDraftFields = DecryptedDraftFields.Remote(expectedFields) + expectedPrimaryAddress(expectedUserId) { primaryAddress } + expectInputDraftMessageId { messageId } + expectStoreAllDraftFieldsSucceeds(expectedUserId, messageId, expectedFields) + expectDecryptedDraftDataSuccess(expectedUserId, messageId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, messageId) + expectObservedMessageAttachments(expectedUserId, messageId) + expectNoInputDraftAction() + expectAttachmentDeleteSucceeds(expectedUserId, expectedSenderEmail, messageId, expectedAttachmentId) + expectStoreParentAttachmentSucceeds(expectedUserId, messageId) + expectObserveMessageSendingError(expectedUserId, messageId) + expectMessagePassword(expectedUserId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, messageId) + + // When + viewModel.submit(ComposerAction.RemoveAttachment(expectedAttachmentId)) + + // Then + coVerify { deleteAttachment(expectedUserId, expectedSenderEmail, messageId, expectedAttachmentId) } + } + + @Test + fun `emit state with effect when attachment file size exceeded`() = runTest { + // Given + val uri = mockk() + val primaryAddress = UserAddressSample.PrimaryAddress + val expectedUserId = expectedUserId { UserIdSample.Primary } + val messageId = MessageIdSample.Invoice + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedDraftBody = DraftBody("I am plaintext") + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + val decryptedDraftFields = DecryptedDraftFields.Remote(expectedFields) + expectedPrimaryAddress(expectedUserId) { primaryAddress } + expectInputDraftMessageId { messageId } + expectStoreAllDraftFieldsSucceeds(expectedUserId, messageId, expectedFields) + expectStoreAttachmentsFailed( + expectedUserId = expectedUserId, + expectedMessageId = messageId, + expectedSenderEmail = expectedSenderEmail, + expectedUriList = listOf(uri), + storeAttachmentError = StoreDraftWithAttachmentError.FileSizeExceedsLimit + ) + expectDecryptedDraftDataSuccess(expectedUserId, messageId) { decryptedDraftFields } + expectStartDraftSync(expectedUserId, messageId) + expectObservedMessageAttachments(expectedUserId, messageId) + expectNoInputDraftAction() + expectStoreParentAttachmentSucceeds(expectedUserId, messageId) + expectObserveMessageSendingError(expectedUserId, messageId) + expectMessagePassword(expectedUserId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, messageId) + + // When + viewModel.submit(ComposerAction.AttachmentsAdded(listOf(uri))) + + // Then + viewModel.state.test { + val expected = Effect.of(Unit) + val actual = awaitItem().attachmentsFileSizeExceeded + assertEquals(expected, actual) + } + } + + @Test + fun `stop syncing draft for current messageId when app is put in background`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectStopContinuousDraftUploadSucceeds() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + + // When + viewModel.state // app is in foreground + appInBackgroundStateFlow.emit(true) // app is in background + + // Then + coVerifyOrder { + draftUploaderMock.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + draftUploaderMock.stopContinuousUpload() + } + } + + @Test + fun `start syncing draft for current messageId when app is put back in foreground`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectStopContinuousDraftUploadSucceeds() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + + // When + viewModel.state // app is in foreground + appInBackgroundStateFlow.emit(true) // app is in background + appInBackgroundStateFlow.emit(false) // app is in foreground again + + // Then + coVerifyOrder { + draftUploaderMock.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + draftUploaderMock.stopContinuousUpload() + draftUploaderMock.startContinuousUpload(userId, messageId, DraftAction.Compose, any()) + } + } + + @Test + fun `should update state with message password info when message password changes`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + + // When + viewModel.state.test { + // Then + assertTrue(awaitItem().isMessagePasswordSet) + } + } + + @Test + fun `should set recipient to state when recipient was given as an input`() = runTest { + // Given + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedRecipient = RecipientSample.John + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + + expectNoInputDraftAction() + expectNoInputDraftMessageId() + + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectedMessageId { expectedMessageId } + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectContacts() + mockParticipantMapper() + expectInputDraftAction { DraftAction.ComposeToAddresses(listOf(expectedRecipient.address)) } + expectStoreDraftRecipientsSucceeds( + expectedMessageId, + expectedSenderEmail, + expectedUserId, + listOf(expectedRecipient) + ) + expectStartDraftSync(expectedUserId, expectedMessageId) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectAddressValidation(expectedRecipient.address, true) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + + assertEquals(viewModel.state.value.fields.to.first(), RecipientUiModel.Valid(expectedRecipient.address)) + } + + @Test + fun `should emit state for showing bottom sheet when action for setting expiration time is submitted`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + + // When + viewModel.submit(ComposerAction.OnSetExpirationTimeRequested) + + // Then + viewModel.state.test { + assertEquals(Effect.of(true), awaitItem().changeBottomSheetVisibility) + } + } + + @Test + fun `should emit state for hiding bottom sheet when action for saving expiration time is submitted`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expirationTime = 1.days + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectSaveExpirationTimeForDraft(userId, messageId, expectedSenderEmail, expirationTime) + expectObserveMessageExpirationTime(userId, messageId) + + // When + viewModel.submit(ComposerAction.ExpirationTimeSet(duration = expirationTime)) + + // Then + viewModel.state.test { + coVerify { saveMessageExpirationTime(userId, messageId, expectedSenderEmail, expirationTime) } + assertEquals(Effect.of(false), awaitItem().changeBottomSheetVisibility) + } + } + + @Test + fun `should emit state for showing an error when saving expiration time has failed`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expirationTime = 1.days + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(userId, messageId) + coEvery { + saveMessageExpirationTime(userId, messageId, expectedSenderEmail, 1.days) + } returns DataError.Local.DbWriteFailed.left() + + // When + viewModel.submit(ComposerAction.ExpirationTimeSet(duration = expirationTime)) + + // Then + viewModel.state.test { + coVerify { saveMessageExpirationTime(userId, messageId, expectedSenderEmail, expirationTime) } + assertEquals(Effect.of(TextUiModel(R.string.composer_error_setting_expiration_time)), awaitItem().error) + } + } + + @Test + fun `should emit state with message expiration time when the expiration time has changed`() = runTest { + // Given + val userId = expectedUserId { UserIdSample.Primary } + val messageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + expectedPrimaryAddress(userId) { UserAddressSample.PrimaryAddress } + expectStartDraftSync(userId, messageId) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectObservedMessageAttachments(userId, messageId) + expectInjectAddressSignature(userId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(userId, messageId) + expectMessagePassword(userId, messageId) + expectNoFileShareVia() + val messageExpirationTime = expectObserveMessageExpirationTime(userId, messageId) + + // Then + viewModel.state.test { + assertEquals(messageExpirationTime.expiresIn, awaitItem().messageExpiresIn) + } + } + + @Test + fun `should emit event to confirm sending expiring message when there are external recipients and no password`() = + runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("I am plaintext") + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectNetworkManagerIsDisconnected() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectSendMessageSucceds(expectedUserId, expectedMessageId, expectedFields) + expectStopContinuousDraftUploadSucceeds() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectNoMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + val externalRecipients = expectExternalRecipients(expectedUserId, recipientsTo, recipientsCc, recipientsBcc) + + // Change internal state of the View Model to simulate the existence of all fields + expectedViewModelInitialState( + messageId = expectedMessageId, + senderEmail = expectedSenderEmail, + subject = expectedSubject, + draftBody = expectedDraftBody, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.OnSendMessage) + advanceUntilIdle() + + // Then + viewModel.state.test { + assertEquals(Effect.of(externalRecipients), awaitItem().confirmSendExpiringMessage) + } + } + + @Test + fun `should send message when sending an expiring message to external recipients was confirmed`() = runTest { + // Given + val expectedSubject = Subject("Subject for the message") + val expectedSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val expectedDraftBody = DraftBody("I am plaintext") + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + val recipientsTo = RecipientsTo(listOf(RecipientSample.John)) + val recipientsCc = RecipientsCc(listOf(RecipientSample.John)) + val recipientsBcc = RecipientsBcc(listOf(RecipientSample.John)) + val expectedFields = DraftFields( + expectedSenderEmail, + expectedSubject, + expectedDraftBody, + recipientsTo, + recipientsCc, + recipientsBcc, + null + ) + mockParticipantMapper() + expectNetworkManagerIsDisconnected() + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectSendMessageSucceds(expectedUserId, expectedMessageId, expectedFields) + expectStopContinuousDraftUploadSucceeds() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), expectedSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectExternalRecipients(expectedUserId, recipientsTo, recipientsCc, recipientsBcc) + + // Change internal state of the View Model to simulate the existence of all fields before closing the composer + expectedViewModelInitialState( + messageId = expectedMessageId, + senderEmail = expectedSenderEmail, + subject = expectedSubject, + draftBody = expectedDraftBody, + recipients = Triple(recipientsTo, recipientsCc, recipientsBcc) + ) + + // When + viewModel.submit(ComposerAction.SendExpiringMessageToExternalRecipientsConfirmed) + + // Then + coVerifyOrder { + draftUploaderMock.stopContinuousUpload() + sendMessageMock(expectedUserId, expectedMessageId, expectedFields) + } + } + + @Test + fun `should emit Effect to ReplaceDraftBody when Respond Inline Action`() = runTest { + // Given + val expectedDraftBody = DraftBody(RawDraftBody) + val expectedQuotedHtmlContent = QuotedHtmlContent( + OriginalHtmlQuote("quoted body"), + StyledHtmlQuote("STYLED quoted body") + ) + val expectedQuotedHtmlInPlainText = "quoted body" + val originalSenderEmail = SenderEmail(UserAddressSample.PrimaryAddress.email) + val expectedMessageId = expectedMessageId { MessageIdSample.EmptyDraft } + val expectedUserId = expectedUserId { UserIdSample.Primary } + val action = ComposerAction.RespondInlineRequested + expectedPrimaryAddress(expectedUserId) { UserAddressSample.PrimaryAddress } + expectStoreDraftBodySucceeds( + expectedMessageId, + expectedDraftBody, + expectedQuotedHtmlContent.original, + originalSenderEmail, + expectedUserId + ) + expectNoInputDraftMessageId() + expectNoInputDraftAction() + expectStartDraftSync(expectedUserId, MessageIdSample.EmptyDraft) + expectObservedMessageAttachments(expectedUserId, expectedMessageId) + expectInjectAddressSignature(expectedUserId, expectDraftBodyWithSignature(), originalSenderEmail) + expectObserveMessageSendingError(expectedUserId, expectedMessageId) + expectMessagePassword(expectedUserId, expectedMessageId) + expectNoFileShareVia() + expectObserveMessageExpirationTime(expectedUserId, expectedMessageId) + expectConvertHtmlToPlainTextSucceeds(expectedQuotedHtmlContent, expectedQuotedHtmlInPlainText) + + // Change internal state of the View Model to simulate an existing draft body before changing sender + expectedViewModelInitialState( + messageId = expectedMessageId, + draftBody = expectedDraftBody, + quotedBody = expectedQuotedHtmlContent + ) + + val expectedReplaceDraftBodyTextUiModel = TextUiModel( + "${expectedDraftBody.value}$expectedQuotedHtmlInPlainText" + ) + + // When + viewModel.submit(action) + + // Then + assertEquals(expectedReplaceDraftBodyTextUiModel, viewModel.state.value.replaceDraftBody.consume()) + assertNull(viewModel.state.value.fields.quotedBody) + } + + @AfterTest + fun tearDown() { + unmockkObject(ComposerDraftState.Companion) + } + + private fun expectConvertHtmlToPlainTextSucceeds( + expectedQuotedHtmlContent: QuotedHtmlContent, + expectedQuotedHtmlInPlainText: String + ) { + every { convertHtmlToPlainText(expectedQuotedHtmlContent.styled.value) } returns expectedQuotedHtmlInPlainText + } + + private fun expectStyleQuotedHtml(originalHtmlQuote: OriginalHtmlQuote?, styledHtmlQuote: () -> StyledHtmlQuote) = + styledHtmlQuote().also { coEvery { styleQuotedHtml(originalHtmlQuote!!) } returns it } + + private fun expectParentMessageToDraftFieldsSuccess( + userId: UserId, + messageWithDecryptedBody: MessageWithDecryptedBody, + action: DraftAction, + draftFields: () -> DraftFields + ) = draftFields().also { + coEvery { parentMessageToDraftFields(userId, messageWithDecryptedBody, action) } returns it.right() + } + + private fun expectParentDraftDataError( + userId: UserId, + messageId: MessageId, + error: () -> DataError.Local + ) = error().also { coEvery { getLocalMessageDecrypted(userId, messageId) } returns it.left() } + + private fun expectGetMessageWithDecryptedBodySuccess( + userId: UserId, + messageId: MessageId, + responseDelay: Long = 0L, + result: () -> MessageWithDecryptedBody + ) = result().also { messageWithDecryptedBody -> + coEvery { getLocalMessageDecrypted(userId, messageId) } coAnswers { + delay(responseDelay) + messageWithDecryptedBody.right() + } + } + + private fun expectDecryptedDraftDataError( + userId: UserId, + draftId: MessageId, + error: () -> DataError + ) = error().also { coEvery { getDecryptedDraftFields(userId, draftId) } returns it.left() } + + private fun expectDecryptedDraftDataSuccess( + userId: UserId, + draftId: MessageId, + responseDelay: Long = 0L, + result: () -> DecryptedDraftFields + ) = result().also { decryptedDraftFields -> + coEvery { getDecryptedDraftFields(userId, draftId) } coAnswers { + delay(responseDelay) + decryptedDraftFields.right() + } + } + + private fun expectInputDraftAction(draftAction: () -> DraftAction) = draftAction().also { + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns it.serialize() + } + + private fun expectNoInputDraftAction() { + every { savedStateHandle.get(ComposerScreen.SerializedDraftActionKey) } returns null + } + + private fun expectNoFileShareVia() { + every { savedStateHandle.get(ComposerScreen.DraftActionForShareKey) } returns null + } + + private fun expectNoInputDraftMessageId() { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns null + } + + private fun expectDraftBodyWithSignature() = DraftBody( + """ + Email body + + + Signature + """.trimIndent() + ) + + private fun expectInjectAddressSignature( + expectedUserId: UserId, + expectedDraftBody: DraftBody, + senderEmail: SenderEmail + ) { + coEvery { injectAddressSignature(expectedUserId, any(), senderEmail, any()) } returns expectedDraftBody.right() + } + + private fun expectObserveMessageSendingError( + expectedUserId: UserId, + expectedMessageId: MessageId, + sendingError: SendingError? = null + ) { + coEvery { observeMessageSendingError(expectedUserId, expectedMessageId) } returns if (sendingError != null) { + flowOf(sendingError) + } else { + flowOf() + } + } + + private fun expectInputDraftMessageId(draftId: () -> MessageId) = draftId().also { + every { savedStateHandle.get(ComposerScreen.DraftMessageIdKey) } returns it.id + } + + private fun expectStopContinuousDraftUploadSucceeds() { + coEvery { draftUploaderMock.stopContinuousUpload() } returns Unit + } + + private fun expectUploadDraftSucceeds(expectedUserId: UserId, expectedMessageId: MessageId) { + coEvery { draftUploaderMock.upload(expectedUserId, expectedMessageId) } returns Unit + } + + private fun expectSendMessageSucceds( + expectedUserId: UserId, + expectedMessageId: MessageId, + expectedFields: DraftFields + ) { + coEvery { sendMessageMock.invoke(expectedUserId, expectedMessageId, expectedFields) } returns Unit + } + + private fun expectNetworkManagerIsConnected() { + every { networkManagerMock.isConnectedToNetwork() } returns true + } + + private fun expectNetworkManagerIsDisconnected() { + every { networkManagerMock.isConnectedToNetwork() } returns false + } + + private fun expectStartDraftSync( + userId: UserId, + messageId: MessageId, + action: DraftAction = DraftAction.Compose + ) { + coEvery { draftUploaderMock.startContinuousUpload(userId, messageId, action, any()) } returns Unit + } + + private fun expectedViewModelInitialState( + messageId: MessageId, + senderEmail: SenderEmail = SenderEmail(""), + subject: Subject = Subject(""), + draftBody: DraftBody = DraftBody(""), + quotedBody: QuotedHtmlContent? = null, + recipients: Triple = Triple( + RecipientsTo(emptyList()), + RecipientsCc(emptyList()), + RecipientsBcc(emptyList()) + ) + ) { + val expected = ComposerDraftState( + fields = ComposerFields( + messageId, + SenderUiModel(senderEmail.value), + recipients.first.value.map { RecipientUiModel.Valid(it.address) }, + recipients.second.value.map { RecipientUiModel.Valid(it.address) }, + recipients.third.value.map { RecipientUiModel.Valid(it.address) }, + subject.value, + draftBody.value, + quotedBody + ), + attachments = AttachmentGroupUiModel(attachments = emptyList()), + premiumFeatureMessage = Effect.empty(), + recipientValidationError = Effect.empty(), + error = Effect.empty(), + isSubmittable = false, + senderAddresses = emptyList(), + changeBottomSheetVisibility = Effect.empty(), + closeComposer = Effect.empty(), + closeComposerWithDraftSaved = Effect.empty(), + isLoading = false, + closeComposerWithMessageSending = Effect.empty(), + closeComposerWithMessageSendingOffline = Effect.empty(), + confirmSendingWithoutSubject = Effect.empty(), + changeFocusToField = Effect.empty(), + attachmentsFileSizeExceeded = Effect.empty(), + attachmentsReEncryptionFailed = Effect.empty(), + warning = Effect.empty(), + replaceDraftBody = Effect.empty(), + isMessagePasswordSet = false, + messageExpiresIn = Duration.ZERO, + confirmSendExpiringMessage = Effect.empty(), + isDeviceContactsSuggestionsEnabled = false, + isDeviceContactsSuggestionsPromptEnabled = false, + openImagePicker = Effect.empty(), + shouldRestrictWebViewHeight = false + ) + + mockkObject(ComposerDraftState.Companion) + every { ComposerDraftState.initial(messageId) } returns expected + } + + private fun expectedMessageId(messageId: () -> MessageId): MessageId = messageId().also { + every { provideNewDraftIdMock() } returns it + } + + private fun expectedUserId(userId: () -> UserId): UserId = userId().also { + coEvery { observePrimaryUserIdMock() } returns flowOf(it) + } + + private fun expectedPrimaryAddress(userId: UserId, userAddress: () -> UserAddress) = userAddress().also { + coEvery { getPrimaryAddressMock(userId) } returns it.right() + } + + private fun expectedPrimaryAddressError(userId: UserId, dataError: () -> DataError) = dataError().also { + coEvery { getPrimaryAddressMock(userId) } returns it.left() + } + + private fun expectedGetComposerSenderAddresses(addresses: () -> List): List = + addresses().also { coEvery { getComposerSenderAddresses() } returns it.right() } + + private fun expectedGetComposerSenderAddressesError( + error: () -> GetComposerSenderAddresses.Error + ): GetComposerSenderAddresses.Error = error().also { coEvery { getComposerSenderAddresses() } returns it.left() } + + private fun expectStoreDraftWithParentAttachmentsSucceeds( + userId: UserId, + messageId: MessageId, + messageWithDecryptedBody: MessageWithDecryptedBody, + senderEmail: SenderEmail, + action: DraftAction + ) { + coEvery { + storeDraftWithParentAttachments(userId, messageId, messageWithDecryptedBody, senderEmail, action) + } returns Unit.right() + } + + + private fun expectStoreDraftBodySucceeds( + expectedMessageId: MessageId, + expectedDraftBody: DraftBody, + expectedQuotedBody: OriginalHtmlQuote?, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId + ) { + coEvery { + storeDraftWithBodyMock( + expectedUserId, + expectedMessageId, + expectedDraftBody, + expectedQuotedBody, + expectedSenderEmail + ) + } returns Unit.right() + } + + @SuppressWarnings("LongParameterList") + private fun expectStoreDraftBodyFails( + expectedMessageId: MessageId, + expectedDraftBody: DraftBody, + expectedQuotedBody: OriginalHtmlQuote?, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId, + error: () -> StoreDraftWithBodyError + ) = error().also { + coEvery { + storeDraftWithBodyMock( + expectedUserId, + expectedMessageId, + expectedDraftBody, + expectedQuotedBody, + expectedSenderEmail + ) + } returns it.left() + } + + private fun expectStoreDraftSubjectSucceeds( + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId, + expectedSubject: Subject + ) { + coEvery { + storeDraftWithSubjectMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + expectedSubject + ) + } returns Unit.right() + } + + private fun expectStoreDraftSubjectFails( + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId, + expectedSubject: Subject, + error: () -> StoreDraftWithSubject.Error + ) = error().also { + coEvery { + storeDraftWithSubjectMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + expectedSubject + ) + } returns it.left() + } + + private fun expectStoreDraftRecipientsSucceeds( + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId, + expectedTo: List? = null, + expectedCc: List? = null, + expectedBcc: List? = null + ) { + coEvery { + storeDraftWithRecipientsMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + to = expectedTo, + cc = expectedCc, + bcc = expectedBcc + ) + } returns Unit.right() + } + + private fun expectStoreDraftRecipientsFails( + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUserId: UserId, + expectedTo: List? = emptyList(), + expectedCc: List? = emptyList(), + expectedBcc: List? = emptyList(), + error: () -> StoreDraftWithRecipients.Error + ) = error().also { + coEvery { + storeDraftWithRecipientsMock( + expectedUserId, + expectedMessageId, + expectedSenderEmail, + to = expectedTo, + cc = expectedCc, + bcc = expectedBcc + ) + } returns it.left() + } + + private fun expectStoreAllDraftFieldsSucceeds( + expectedUserId: UserId, + expectedMessageId: MessageId, + expectedFields: DraftFields + ) { + coEvery { + storeDraftWithAllFields( + expectedUserId, + expectedMessageId, + expectedFields + ) + } returns Unit.right() + } + + private fun expectStoreAttachmentsSucceeds( + expectedUserId: UserId, + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUriList: List + ) { + coEvery { + storeAttachments(expectedUserId, expectedMessageId, expectedSenderEmail, expectedUriList) + } returns Unit.right() + } + + private fun expectStoreAttachmentsFailed( + expectedUserId: UserId, + expectedMessageId: MessageId, + expectedSenderEmail: SenderEmail, + expectedUriList: List, + storeAttachmentError: StoreDraftWithAttachmentError + ) { + coEvery { + storeAttachments(expectedUserId, expectedMessageId, expectedSenderEmail, expectedUriList) + } returns storeAttachmentError.left() + } + + private fun expectContacts(): List { + val expectedContacts = listOf(ContactSample.Doe, ContactSample.John) + coEvery { getContactsMock.invoke(UserIdSample.Primary) } returns expectedContacts.right() + return expectedContacts + } + + private fun expectSearchContacts( + expectedUserId: UserId, + expectedSearchTerm: String, + expectedContacts: List + ): List { + coEvery { + searchContactsMock.invoke(expectedUserId, expectedSearchTerm) + } returns flowOf(expectedContacts.right()) + return expectedContacts + } + + private fun expectSearchDeviceContacts( + expectedSearchTerm: String, + expectedDeviceContacts: List + ): List { + coEvery { + searchDeviceContactsMock.invoke(expectedSearchTerm) + } returns expectedDeviceContacts.right() + return expectedDeviceContacts + } + + private fun expectSearchContactGroups( + expectedUserId: UserId, + expectedSearchTerm: String, + expectedContactGroups: List + ): List { + coEvery { + searchContactGroupsMock.invoke(expectedUserId, expectedSearchTerm) + } returns flowOf(expectedContactGroups.right()) + return expectedContactGroups + } + + private fun expectIsDeviceContactsSuggestionsEnabled(enabled: Boolean) { + every { isDeviceContactsSuggestionsEnabledMock.invoke() } returns enabled + } + + private fun expectObservedMessageAttachments(userId: UserId, messageId: MessageId) { + every { + observeMessageAttachments(userId, messageId) + } returns flowOf(listOf(MessageAttachmentSample.invoice)) + } + + private fun expectAttachmentDeleteSucceeds( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId, + attachmentId: AttachmentId + ) { + coEvery { deleteAttachment(userId, senderEmail, messageId, attachmentId) } returns Unit.right() + } + + private fun expectStoreParentAttachmentSucceeds(userId: UserId, messageId: MessageId) { + coJustRun { storeExternalAttachmentStates(userId, messageId) } + } + + private fun expectReEncryptAttachmentSucceeds( + userId: UserId, + messageId: MessageId, + previousSenderEmail: SenderEmail, + newSenderEmail: SenderEmail + ) { + coEvery { reEncryptAttachments(userId, messageId, previousSenderEmail, newSenderEmail) } returns Unit.right() + } + + private fun expectReEncryptAttachmentFails( + userId: UserId, + messageId: MessageId, + previousSenderEmail: SenderEmail, + newSenderEmail: SenderEmail + ) { + coEvery { + reEncryptAttachments(userId, messageId, previousSenderEmail, newSenderEmail) + } returns AttachmentReEncryptionError.FailedToEncryptAttachmentKeyPackets.left() + } + + private fun expectDeleteAllAttachmentsSucceeds( + userId: UserId, + senderEmail: SenderEmail, + messageId: MessageId + ) { + coJustRun { deleteAllAttachments(userId, senderEmail, messageId) } + } + + private fun expectMessagePassword(userId: UserId, messageId: MessageId) { + val messagePassword = MessagePassword(userId, messageId, "password", null) + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(messagePassword) + } + + private fun expectNoMessagePassword(userId: UserId, messageId: MessageId) { + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + } + + private fun expectAddressValidation(address: String, expectedResult: Boolean) { + every { isValidEmailAddressMock(address) } returns expectedResult + } + + private fun expectSaveExpirationTimeForDraft( + userId: UserId, + messageId: MessageId, + senderEmail: SenderEmail, + expirationTime: Duration + ) { + coEvery { saveMessageExpirationTime(userId, messageId, senderEmail, expirationTime) } returns Unit.right() + } + + private fun expectObserveMessageExpirationTime(userId: UserId, messageId: MessageId) = + MessageExpirationTime(userId, messageId, 1.days).also { + coEvery { observeMessageExpirationTime(userId, messageId) } returns flowOf(it) + } + + private fun mockParticipantMapper() { + val expectedContacts = expectContacts() + every { + participantMapperMock.recipientUiModelToParticipant( + RecipientUiModel.Valid("valid@email.com"), + expectedContacts + ) + } returns Recipient("valid@email.com", "Valid Email", false) + every { + participantMapperMock.recipientUiModelToParticipant( + RecipientUiModel.Valid(RecipientSample.John.address), + any() + ) + } returns Recipient(RecipientSample.John.address, RecipientSample.John.name, false) + } + + private fun expectValidSenderAddress(userId: UserId, senderEmail: SenderEmail) { + coEvery { + validateSenderAddress(userId, senderEmail) + } returns ValidateSenderAddress.ValidationResult.Valid(senderEmail).right() + } + + private fun expectInvalidSenderAddress( + userId: UserId, + invalid: SenderEmail, + useInstead: SenderEmail, + reason: ValidateSenderAddress.ValidationError + ) { + coEvery { + validateSenderAddress(userId, invalid) + } returns ValidateSenderAddress.ValidationResult.Invalid(useInstead, invalid, reason).right() + } + + private fun expectExternalRecipients( + userId: UserId, + recipientsTo: RecipientsTo, + recipientsCc: RecipientsCc, + recipientsBcc: RecipientsBcc + ) = listOf(RecipientSample.ExternalEncrypted).also { + coEvery { getExternalRecipients(userId, recipientsTo, recipientsCc, recipientsBcc) } returns it + } + + companion object TestData { + + const val RawDraftBody = "I'm a message body" + + val existingDraftFields = DraftFields( + SenderEmail("author@proton.me"), + Subject("Here is the matter"), + DraftBody("Decrypted body of this draft"), + RecipientsTo(listOf(Recipient("you@proton.ch", "Name"))), + RecipientsCc(emptyList()), + RecipientsBcc(emptyList()), + null + ) + + val draftFieldsWithQuotedBody = DraftFields( + SenderEmail("author@proton.me"), + Subject("Here is the matter"), + DraftBody(""), + RecipientsTo(listOf(Recipient("you@proton.ch", "Name"))), + RecipientsCc(emptyList()), + RecipientsBcc(emptyList()), + OriginalHtmlQuote("
Quoted html of the parent message
") + ) + + const val BaseInitials = "AB" + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModelTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModelTest.kt new file mode 100644 index 0000000000..7e8070b9ac --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/RecipientsViewModelTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import app.cash.turbine.test +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcomposer.domain.repository.ContactsPermissionRepository +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionUiModel +import ch.protonmail.android.mailcomposer.presentation.model.ContactSuggestionsField +import ch.protonmail.android.mailcomposer.presentation.model.RecipientUiModel +import ch.protonmail.android.mailcomposer.presentation.model.RecipientsStateManager +import ch.protonmail.android.mailcomposer.presentation.usecase.SortContactsForSuggestions +import ch.protonmail.android.mailcontact.domain.model.ContactGroup +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import ch.protonmail.android.mailcontact.domain.usecase.SearchContactGroups +import ch.protonmail.android.mailcontact.domain.usecase.SearchContacts +import ch.protonmail.android.mailcontact.domain.usecase.SearchDeviceContacts +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.entity.UserId +import org.junit.Rule +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class RecipientsViewModelTest { + + private val observePrimaryUserId = mockk { + every { this@mockk.invoke() } returns flowOf(userId) + } + private val searchContacts = mockk() + private val searchContactGroups = mockk() + private val searchDeviceContacts = mockk() + private val sortContactsForSuggestions = mockk() + private val contactsPermissionRepository = mockk { + every { this@mockk.observePermissionDenied() } returns flowOf() + } + private val recipientsStateManager = spyk() + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `should emit empty suggestions when the search term is empty`() = runTest { + // Given + val searchTerm = "" + val viewModel = viewModel() + + // When + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + + viewModel.contactsSuggestions.test { + assertTrue(awaitItem().isEmpty()) + } + + viewModel.contactSuggestionsFieldFlow.test { + assertEquals(ContactSuggestionsField.TO, awaitItem()) + } + } + + @Test + fun `should update recipients via the recipients state manager when performing the task`() { + // Given + val recipients = listOf( + RecipientUiModel.Valid("aa@bb.cc"), + RecipientUiModel.Invalid("__") + ) + val viewModel = viewModel() + + // When + viewModel.updateRecipients(recipients, ContactSuggestionsField.BCC) + + // Then + verify { recipientsStateManager.updateRecipients(recipients, ContactSuggestionsField.BCC) } + confirmVerified(recipientsStateManager) + } + + @Test + fun `should close suggestions when close suggestions is called`() = runTest { + // Given + val searchTerm = "" + val viewModel = viewModel() + + // When + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + viewModel.contactSuggestionsFieldFlow.test { + assertEquals(ContactSuggestionsField.TO, awaitItem()) + + viewModel.closeSuggestions() + advanceUntilIdle() + assertEquals(null, awaitItem()) + } + } + + @Test + fun `should emit different fields suggestions when the search field changes`() = runTest { + // Given + val searchTerm = "" + val viewModel = viewModel() + + // When + Then + viewModel.contactsSuggestions.test { + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + assertTrue(awaitItem().isEmpty()) + } + + viewModel.contactSuggestionsFieldFlow.test { + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + assertEquals(ContactSuggestionsField.TO, awaitItem()) + + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.CC) + assertEquals(ContactSuggestionsField.CC, awaitItem()) + + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.BCC) + assertEquals(ContactSuggestionsField.BCC, awaitItem()) + } + } + + @Test + fun `should reflect empty suggestions emissions`() = runTest { + // Given + val searchTerm = "searchTerm" + expectEmptySuggestions(searchTerm) + val viewModel = viewModel() + + // When + Then + viewModel.contactsSuggestions.test { + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + assertTrue(awaitItem().isEmpty()) + } + } + + @Test + fun `should return contact suggestions once sorted`() = runTest { + // Given + val searchTerm = "searchTerm" + val expectedSuggestions = listOf( + ContactSuggestionUiModel.Contact("name", "N", "test@proton.me"), + ContactSuggestionUiModel.ContactGroup("name", listOf("test2@proton.me", "test3@proton.me"), "#FFFFFF") + ) + + val mockedSearchResult = mockk>> { + every { this@mockk.getOrNull() } returns mockk() + } + val mockedSearchGroupResult = mockk>> { + every { this@mockk.getOrNull() } returns mockk() + } + val mockedSearchDeviceResult = spyk>().right() + + coEvery { searchContacts(userId, searchTerm) } returns flowOf(mockedSearchResult) + coEvery { searchContactGroups(userId, searchTerm) } returns flowOf(mockedSearchGroupResult) + coEvery { searchDeviceContacts(searchTerm) } returns mockedSearchDeviceResult + coEvery { sortContactsForSuggestions(any(), any(), any(), 100) } returns expectedSuggestions + + val viewModel = viewModel() + + // When + Then + viewModel.contactsSuggestions.test { + skipItems(1) + viewModel.updateSearchTerm(searchTerm, ContactSuggestionsField.TO) + assertEquals(expectedSuggestions, awaitItem()) + } + } + + @Test + fun `should emit contacts denied state when collected and repository returns a value (true)`() = runTest { + // Given + val expectedValue = true + every { contactsPermissionRepository.observePermissionDenied() } returns flowOf(expectedValue.right()) + val viewModel = viewModel() + + // When + Then + viewModel.contactsPermissionDenied.test { + assertEquals(expectedValue, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should emit contacts non denied state when collected and repository returns a value (false)`() = runTest { + // Given + val expectedValue = false + every { contactsPermissionRepository.observePermissionDenied() } returns flowOf(expectedValue.right()) + val viewModel = viewModel() + + // When + Then + viewModel.contactsPermissionDenied.test { + assertEquals(expectedValue, awaitItem()) + awaitComplete() + } + } + + @Test + fun `should emit contacts non denied state when collected and repository returns an error`() = runTest { + // Given + val expectedValue = false + every { + contactsPermissionRepository.observePermissionDenied() + } returns flowOf(DataError.Local.NoDataCached.left()) + val viewModel = viewModel() + + // When + Then + viewModel.contactsPermissionDenied.test { + assertEquals(expectedValue, awaitItem()) + awaitComplete() + } + } + + private fun viewModel() = RecipientsViewModel( + observePrimaryUserId, + searchContacts, + searchContactGroups, + searchDeviceContacts, + sortContactsForSuggestions, + contactsPermissionRepository, + testDispatcher, + recipientsStateManager + ) + + private fun expectEmptySuggestions(@Suppress("SameParameterValue") forTerm: String) { + coEvery { searchContacts(userId, forTerm) } returns flowOf(emptyList().right()) + coEvery { searchContactGroups(userId, forTerm) } returns flowOf(emptyList().right()) + coEvery { searchDeviceContacts(forTerm) } returns emptyList().right() + coEvery { sortContactsForSuggestions(any(), any(), any(), 100) } returns emptyList() + } + + private companion object { + + val userId = UserId("user-id") + } +} diff --git a/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModelTest.kt b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModelTest.kt new file mode 100644 index 0000000000..1babc94665 --- /dev/null +++ b/mail-composer/presentation/src/test/kotlin/ch/protonmail/android/mailcomposer/presentation/viewmodel/SetMessagePasswordViewModelTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcomposer.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.usecase.ObservePrimaryUserId +import ch.protonmail.android.mailcommon.presentation.Effect +import ch.protonmail.android.mailcomposer.domain.model.MessagePassword +import ch.protonmail.android.mailcomposer.domain.model.SenderEmail +import ch.protonmail.android.mailcomposer.domain.usecase.DeleteMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.ObserveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessagePassword +import ch.protonmail.android.mailcomposer.domain.usecase.SaveMessagePasswordAction +import ch.protonmail.android.mailcomposer.presentation.model.MessagePasswordOperation +import ch.protonmail.android.mailcomposer.presentation.model.SetMessagePasswordState +import ch.protonmail.android.mailcomposer.presentation.reducer.SetMessagePasswordReducer +import ch.protonmail.android.mailcomposer.presentation.ui.SetMessagePasswordScreen +import ch.protonmail.android.mailmessage.domain.sample.MessageIdSample +import ch.protonmail.android.test.utils.rule.MainDispatcherRule +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.util.kotlin.EMPTY_STRING +import me.proton.core.util.kotlin.serialize +import org.junit.Rule +import kotlin.test.Test +import kotlin.test.assertEquals + +class SetMessagePasswordViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val userId = UserIdTestData.userId + private val messageId = MessageIdSample.NewDraftWithSubjectAndBody + private val senderEmail = SenderEmail("sender@pm.me") + + private val deleteMessagePassword = mockk() + private val observeMessagePassword = mockk() + private val saveMessagePassword = mockk() + private val observePrimaryUserId = mockk { + every { this@mockk.invoke() } returns flowOf(userId) + } + private val savedStateHandle = mockk { + every { + get(SetMessagePasswordScreen.InputParamsKey) + } returns SetMessagePasswordScreen.InputParams(messageId, senderEmail).serialize() + } + + private val setMessagePasswordViewModel by lazy { + SetMessagePasswordViewModel( + deleteMessagePassword, + observeMessagePassword, + SetMessagePasswordReducer(), + saveMessagePassword, + observePrimaryUserId, + savedStateHandle + ) + } + + @Test + fun `should initialize screen with correct values when message password does not exist`() = runTest { + // Given + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.state.test { + // Then + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should initialize screen with correct values when message password exists`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val messagePassword = MessagePassword(userId, messageId, password, passwordHint) + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(messagePassword) + + // When + setMessagePasswordViewModel.state.test { + // Then + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = password, + initialMessagePasswordHintValue = passwordHint, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = true, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should validate password when validate password action is submitted and password length is 3`() = runTest { + // Given + val password = "123" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.ValidatePassword(password)) + + // Then + setMessagePasswordViewModel.state.test { + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = true, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should validate password when validate password action is submitted and password length is 22`() = runTest { + // Given + val password = "1234567890123456789012" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.ValidatePassword(password)) + + // Then + setMessagePasswordViewModel.state.test { + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = true, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should validate password when validate password action is submitted and password length is 12`() = runTest { + // Given + val password = "123456789012" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.ValidatePassword(password)) + + // Then + setMessagePasswordViewModel.state.test { + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should validate repeated password when action is submitted and passwords are matching`() = runTest { + // Given + val password = "123456789012" + val repeatedPassword = "123456789012" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.submit( + MessagePasswordOperation.Action.ValidateRepeatedPassword( + password, repeatedPassword + ) + ) + + // Then + setMessagePasswordViewModel.state.test { + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = false, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should validate repeated password when action is submitted and passwords are not matching`() = runTest { + // Given + val password = "123456789012" + val repeatedPassword = "123456789" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + + // When + setMessagePasswordViewModel.submit( + MessagePasswordOperation.Action.ValidateRepeatedPassword( + password, repeatedPassword + ) + ) + + // Then + setMessagePasswordViewModel.state.test { + val expected = SetMessagePasswordState.Data( + initialMessagePasswordValue = EMPTY_STRING, + initialMessagePasswordHintValue = EMPTY_STRING, + hasMessagePasswordError = false, + hasRepeatedMessagePasswordError = true, + isInEditMode = false, + exitScreen = Effect.empty() + ) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `should save message password and close the screen when apply password action is submitted`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + coEvery { saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) } returns Unit.right() + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.ApplyPassword(password, passwordHint)) + + // Then + setMessagePasswordViewModel.state.test { + val item = awaitItem() as SetMessagePasswordState.Data + assertEquals(Effect.of(Unit), item.exitScreen) + coVerify { saveMessagePassword(userId, messageId, senderEmail, password, passwordHint) } + } + } + + @Test + fun `should update message password and close the screen when update password action is submitted`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(null) + coEvery { + saveMessagePassword( + userId, messageId, senderEmail, password, passwordHint, SaveMessagePasswordAction.Update + ) + } returns Unit.right() + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.UpdatePassword(password, passwordHint)) + + // Then + setMessagePasswordViewModel.state.test { + val item = awaitItem() as SetMessagePasswordState.Data + assertEquals(Effect.of(Unit), item.exitScreen) + coVerify { + saveMessagePassword( + userId, messageId, senderEmail, password, passwordHint, SaveMessagePasswordAction.Update + ) + } + } + } + + @Test + fun `should delete message password and close the screen when delete password action is submitted`() = runTest { + // Given + val password = "password" + val passwordHint = "password hint" + val messagePassword = MessagePassword(userId, messageId, password, passwordHint) + coEvery { observeMessagePassword(userId, messageId) } returns flowOf(messagePassword) + coEvery { deleteMessagePassword(userId, messageId) } just runs + + // When + setMessagePasswordViewModel.submit(MessagePasswordOperation.Action.RemovePassword) + + // Then + setMessagePasswordViewModel.state.test { + val item = awaitItem() as SetMessagePasswordState.Data + assertEquals(Effect.of(Unit), item.exitScreen) + coVerify { deleteMessagePassword(userId, messageId) } + } + } +} diff --git a/mail-composer/src/main/AndroidManifest.xml b/mail-composer/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4eec91d978 --- /dev/null +++ b/mail-composer/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + diff --git a/mail-contact/build.gradle.kts b/mail-contact/build.gradle.kts new file mode 100644 index 0000000000..aa58e450c4 --- /dev/null +++ b/mail-contact/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "ch.protonmail.android.mailcontact" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } +} + +dependencies { + api(project(":mail-contact:dagger")) + api(project(":mail-contact:data")) + api(project(":mail-contact:domain")) + api(project(":mail-contact:presentation")) +} diff --git a/mail-contact/dagger/build.gradle.kts b/mail-contact/dagger/build.gradle.kts new file mode 100644 index 0000000000..d2def34457 --- /dev/null +++ b/mail-contact/dagger/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "ch.protonmail.android.mailcontact.dagger" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + implementation(libs.dagger.hilt.android) + + implementation(libs.proton.core.contact) + + implementation(project(":mail-contact:data")) + implementation(project(":mail-contact:domain")) + implementation(project(":mail-contact:presentation")) +} diff --git a/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt b/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt new file mode 100644 index 0000000000..87dea8c784 --- /dev/null +++ b/mail-contact/dagger/src/main/kotlin/ch/protonmail/android/mailcontact/dagger/MailContactModule.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.dagger + +import ch.protonmail.android.mailcontact.data.ContactDetailRepositoryImpl +import ch.protonmail.android.mailcontact.data.ContactGroupRepositoryImpl +import ch.protonmail.android.mailcontact.data.DeviceContactsRepositoryImpl +import ch.protonmail.android.mailcontact.data.DeviceContactsSuggestionsPromptImpl +import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSource +import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSourceImpl +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSourceImpl +import ch.protonmail.android.mailcontact.data.remote.ContactDetailRemoteDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactDetailRemoteDataSourceImpl +import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSourceImpl +import ch.protonmail.android.mailcontact.domain.DeviceContactsSuggestionsPrompt +import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository +import ch.protonmail.android.mailcontact.domain.repository.ContactGroupRepository +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class MailContactModule { + + @Binds + @Reusable + abstract fun bindContactDetailLocalDataSource(impl: ContactDetailLocalDataSourceImpl): ContactDetailLocalDataSource + + @Binds + @Reusable + abstract fun bindContactDetailRemoteDataSource( + impl: ContactDetailRemoteDataSourceImpl + ): ContactDetailRemoteDataSource + + @Binds + @Reusable + abstract fun bindContactDetailRepository(impl: ContactDetailRepositoryImpl): ContactDetailRepository + + @Binds + @Reusable + abstract fun bindContactGroupLocalDataSource(impl: ContactGroupLocalDataSourceImpl): ContactGroupLocalDataSource + + @Binds + @Reusable + abstract fun bindContactGroupRemoteDataSource(impl: ContactGroupRemoteDataSourceImpl): ContactGroupRemoteDataSource + + @Binds + @Reusable + abstract fun bindContactGroupRepository(impl: ContactGroupRepositoryImpl): ContactGroupRepository + + @Binds + @Reusable + abstract fun bindDeviceContactsRepository(impl: DeviceContactsRepositoryImpl): DeviceContactsRepository + + @Binds + @Singleton + abstract fun bindDeviceContactsSuggestionsPrompt( + impl: DeviceContactsSuggestionsPromptImpl + ): DeviceContactsSuggestionsPrompt + +} diff --git a/mail-contact/data/build.gradle.kts b/mail-contact/data/build.gradle.kts new file mode 100644 index 0000000000..572c1e14a5 --- /dev/null +++ b/mail-contact/data/build.gradle.kts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("kapt") + kotlin("plugin.serialization") +} + +android { + namespace = "ch.protonmail.android.mailcontact.data" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + kapt(libs.bundles.app.annotationProcessors) + + implementation(libs.bundles.module.data) + implementation(libs.androidx.hilt.work) + + implementation(libs.proton.core.contact) + implementation(libs.proton.core.label) + + implementation(project(":mail-common:data")) + implementation(project(":mail-common:domain")) + implementation(project(":mail-label:domain")) + implementation(project(":mail-contact:domain")) + + testImplementation(libs.bundles.test) + testImplementation(project(":test:test-data")) + testImplementation(project(":mail-common:domain")) +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDataStoreProvider.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDataStoreProvider.kt new file mode 100644 index 0000000000..d4b01cb2ac --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDataStoreProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class ContactDataStoreProvider @Inject constructor( + @ApplicationContext context: Context +) { + + private val Context.contactDataStore: DataStore by preferencesDataStore( + name = "contactPrefDataStore" + ) + val contactDataStore = context.contactDataStore +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImpl.kt new file mode 100644 index 0000000000..6e647317e1 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImpl.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactDetailRemoteDataSource +import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository +import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository.ContactDetailErrors +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ContactDetailRepositoryImpl @Inject constructor( + private val contactDetailLocalDataSource: ContactDetailLocalDataSource, + private val contactDetailRemoteDataSource: ContactDetailRemoteDataSource +) : ContactDetailRepository { + + override suspend fun deleteContact(userId: UserId, contactId: ContactId): Either = + either { + Either.catch { contactDetailLocalDataSource.deleteContact(contactId) } + .mapLeft { ContactDetailErrors.ContactDetailLocalDataSourceError } + .bind() + contactDetailRemoteDataSource.deleteContact(userId, contactId) + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImpl.kt new file mode 100644 index 0000000000..5b944f8d57 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImpl.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSource +import ch.protonmail.android.mailcontact.domain.repository.ContactGroupRepository +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import javax.inject.Inject + +class ContactGroupRepositoryImpl @Inject constructor( + private val contactGroupLocalDataSource: ContactGroupLocalDataSource, + private val contactGroupRemoteDataSource: ContactGroupRemoteDataSource +) : ContactGroupRepository { + + override suspend fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ): Either { + + contactGroupLocalDataSource.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + + Either.catch { + contactGroupRemoteDataSource.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + }.onLeft { return ContactGroupRepository.ContactGroupErrors.RemoteDataSourceError.left() } + + return Unit.right() + } + + override suspend fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ): Either { + + contactGroupLocalDataSource.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + + Either.catch { + contactGroupRemoteDataSource.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + }.onLeft { return ContactGroupRepository.ContactGroupErrors.RemoteDataSourceError.left() } + + return Unit.right() + } + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt new file mode 100644 index 0000000000..42c1df5616 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImpl.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import android.content.Context +import android.provider.ContactsContract +import androidx.core.database.getStringOrNull +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.model.DeviceContact +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withContext +import me.proton.core.util.kotlin.DispatcherProvider +import timber.log.Timber +import javax.inject.Inject + +class DeviceContactsRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val dispatcherProvider: DispatcherProvider +) : DeviceContactsRepository { + + override suspend fun getDeviceContacts( + query: String + ): Either> { + @Suppress("TooGenericExceptionCaught") + return withContext(dispatcherProvider.Io) { + try { + queryContacts(query).right() + } catch (e: SecurityException) { + Timber.e(e, "Failed to query contacts due to permission issue") + DeviceContactsRepository.DeviceContactsErrors.PermissionDenied.left() + } catch (e: Exception) { + Timber.e(e, "Failed to query contacts") + DeviceContactsRepository.DeviceContactsErrors.UnknownError.left() + } + } + } + + private fun queryContacts(query: String): List { + // If the user searches for "_" or "%", they should be treated as literals. + val escapedQuery = query + .replace("_", "\\_") + .replace("%", "\\%") + + val selectionArgs = arrayOf("%$escapedQuery%", "%$escapedQuery%", "%$escapedQuery%") + + return context.contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + ANDROID_PROJECTION, + ANDROID_SELECTION, + selectionArgs, + ANDROID_ORDER_BY + )?.use { cursor -> + val contacts = mutableListOf() + + val displayNameIndex = cursor.getColumnIndex( + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + ).takeIf { it >= 0 } + + val emailIndex = cursor.getColumnIndex( + ContactsContract.CommonDataKinds.Email.ADDRESS + ).takeIf { it >= 0 } + + for (position in 0 until cursor.count) { + if (!cursor.moveToPosition(position)) continue + val emailAddress = emailIndex?.let { cursor.getStringOrNull(it) } ?: continue + + // Fallback to email address if for some reason the display name can't be obtained + val displayName = displayNameIndex?.let { cursor.getStringOrNull(it) } ?: emailAddress + contacts.add(DeviceContact(name = displayName, email = emailAddress)) + } + + contacts + } ?: emptyList() + } + + companion object { + + private const val ANDROID_ORDER_BY = ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + " ASC" + + private val ANDROID_SELECTION = """ + |${ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\' + |OR ${ContactsContract.CommonDataKinds.Email.ADDRESS} LIKE ? ESCAPE '\' + |OR ${ContactsContract.CommonDataKinds.Email.DATA} LIKE ? ESCAPE '\' + """.trimMargin() + + private val ANDROID_PROJECTION = arrayOf( + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.DATA + ) + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsSuggestionsPromptImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsSuggestionsPromptImpl.kt new file mode 100644 index 0000000000..a26dd7e463 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsSuggestionsPromptImpl.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data + +import androidx.datastore.preferences.core.booleanPreferencesKey +import ch.protonmail.android.mailcommon.data.mapper.safeData +import ch.protonmail.android.mailcommon.data.mapper.safeEdit +import ch.protonmail.android.mailcontact.domain.DeviceContactsSuggestionsPrompt +import ch.protonmail.android.mailcontact.domain.usecase.featureflags.IsDeviceContactsSuggestionsEnabled +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DeviceContactsSuggestionsPromptImpl @Inject constructor( + private val isDeviceContactsSuggestionsEnabled: IsDeviceContactsSuggestionsEnabled, + private val contactDataStoreProvider: ContactDataStoreProvider +) : DeviceContactsSuggestionsPrompt { + + private val isPromptEnabledPrefKey = + booleanPreferencesKey(DEVICE_CONTACT_SUGGESTIONS_PROMPT_ENABLED_PREF_KEY) + + override suspend fun setPromptDisabled() { + contactDataStoreProvider.contactDataStore.safeEdit { + it[isPromptEnabledPrefKey] = false + } + } + + override suspend fun getPromptEnabled(): Boolean { + val promptEnabledPreference = contactDataStoreProvider.contactDataStore.safeData.map { prefsEither -> + prefsEither.map { prefs -> + prefs[isPromptEnabledPrefKey] ?: DEFAULT_VALUE + } + }.firstOrNull()?.getOrNull() ?: DEFAULT_VALUE + + return isDeviceContactsSuggestionsEnabled() && promptEnabledPreference + } + + companion object { + + const val DEFAULT_VALUE = true + + @Suppress("VariableMaxLength") + const val DEVICE_CONTACT_SUGGESTIONS_PROMPT_ENABLED_PREF_KEY = "deviceContactSuggestionsPromptEnabledPrefKey" + } + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSource.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSource.kt new file mode 100644 index 0000000000..ca7d02792e --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSource.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import me.proton.core.contact.domain.entity.ContactId + +interface ContactDetailLocalDataSource { + + suspend fun deleteContact(contactId: ContactId) + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImpl.kt new file mode 100644 index 0000000000..3b7b61a029 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.repository.ContactLocalDataSource +import javax.inject.Inject + +class ContactDetailLocalDataSourceImpl @Inject constructor( + private val contactLocalDataSource: ContactLocalDataSource +) : ContactDetailLocalDataSource { + + override suspend fun deleteContact(contactId: ContactId) { + contactLocalDataSource.deleteContacts(contactId) + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSource.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSource.kt new file mode 100644 index 0000000000..b442b9a345 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId + +interface ContactGroupLocalDataSource { + + suspend fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) + + suspend fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImpl.kt new file mode 100644 index 0000000000..906f4976eb --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImpl.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.contact.domain.repository.ContactLocalDataSource +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import javax.inject.Inject + +class ContactGroupLocalDataSourceImpl @Inject constructor( + private val contactLocalDataSource: ContactLocalDataSource +) : ContactGroupLocalDataSource { + + override suspend fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + val contactEmailsNotInGroup = contactLocalDataSource.observeAllContacts(userId).firstOrNull()?.map { contact -> + contact.contactEmails.filter { contactEmail -> + contactEmail.id in contactEmailIds && labelId.id !in contactEmail.labelIds + } + }?.flatten() ?: emptyList() + + val contactEmailsWithAddedLabelId = contactEmailsNotInGroup.map { + it.copy(labelIds = it.labelIds.plus(labelId.id)) + } + + if (contactEmailsWithAddedLabelId.isNotEmpty()) { + contactLocalDataSource.upsertContactEmails( + *contactEmailsWithAddedLabelId.toTypedArray() + ) + } + } + + override suspend fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + val contactEmailsInGroup = contactLocalDataSource.observeAllContacts(userId).firstOrNull()?.map { contact -> + contact.contactEmails.filter { contactEmail -> + contactEmail.id in contactEmailIds && labelId.id in contactEmail.labelIds + } + }?.flatten() ?: emptyList() + + val contactEmailsWithRemovedLabelId = contactEmailsInGroup.map { + it.copy(labelIds = it.labelIds.minus(labelId.id)) + } + + if (contactEmailsWithRemovedLabelId.isNotEmpty()) { + contactLocalDataSource.upsertContactEmails( + *contactEmailsWithRemovedLabelId.toTypedArray() + ) + } + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSource.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSource.kt new file mode 100644 index 0000000000..96142e2843 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSource.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId + +interface ContactDetailRemoteDataSource { + + fun deleteContact(userId: UserId, contactId: ContactId) + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..8e308094ac --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImpl.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ContactDetailRemoteDataSourceImpl @Inject constructor( + private val enqueuer: Enqueuer +) : ContactDetailRemoteDataSource { + + override fun deleteContact(userId: UserId, contactId: ContactId) { + enqueuer.enqueue(userId, DeleteContactWorker.params(userId.id, contactId.id)) + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupApi.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupApi.kt new file mode 100644 index 0000000000..aa3d9af470 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupApi.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import ch.protonmail.android.mailcontact.data.remote.resource.LabelContactEmailsBody +import ch.protonmail.android.mailcontact.data.remote.resource.UnlabelContactEmailsBody +import ch.protonmail.android.mailcontact.data.remote.response.LabelContactEmailsResponse +import ch.protonmail.android.mailcontact.data.remote.response.UnlabelContactEmailsResponse +import me.proton.core.network.data.protonApi.BaseRetrofitApi +import retrofit2.http.Body +import retrofit2.http.PUT + +interface ContactGroupApi : BaseRetrofitApi { + + @PUT("contacts/v4/contacts/emails/label") + suspend fun labelContactEmails(@Body body: LabelContactEmailsBody): LabelContactEmailsResponse + + @PUT("contacts/v4/contacts/emails/unlabel") + suspend fun unlabelContactEmails(@Body body: UnlabelContactEmailsBody): UnlabelContactEmailsResponse +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSource.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSource.kt new file mode 100644 index 0000000000..6bf8f58295 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId + +interface ContactGroupRemoteDataSource { + + fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) + + fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImpl.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..6c404d8edb --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImpl.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import javax.inject.Inject + +class ContactGroupRemoteDataSourceImpl @Inject constructor( + private val enqueuer: Enqueuer +) : ContactGroupRemoteDataSource { + + override fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + enqueuer.enqueue( + userId, + EditMembersOfContactGroupWorker.params( + userId = userId, + labelId = labelId, + labelContactEmailIds = contactEmailIds, + unlabelContactEmailIds = emptySet() + ) + ) + } + + override fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + enqueuer.enqueue( + userId, + EditMembersOfContactGroupWorker.params( + userId = userId, + labelId = labelId, + labelContactEmailIds = emptySet(), + unlabelContactEmailIds = contactEmailIds + ) + ) + } +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorker.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorker.kt new file mode 100644 index 0000000000..57e959c16b --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorker.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.repository.ContactRemoteDataSource +import me.proton.core.domain.entity.UserId + +@HiltWorker +class DeleteContactWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val remoteDataSource: ContactRemoteDataSource +) : CoroutineWorker(context, workerParameters) { + + override suspend fun doWork(): Result { + val userId = inputData.getString(RawUserIdKey) + val contactId = inputData.getString(RawContactIdKey) + + if (userId == null || contactId == null) { + return Result.failure() + } + + return kotlin.runCatching { + remoteDataSource.deleteContacts(UserId(userId), listOf(ContactId(contactId))) + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.failure() } + ) + } + + companion object { + + internal const val RawUserIdKey = "userId" + internal const val RawContactIdKey = "contactId" + + fun params(userId: String, contactId: String) = mapOf( + RawUserIdKey to userId, + RawContactIdKey to contactId + ) + + } + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorker.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorker.kt new file mode 100644 index 0000000000..56da665f10 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorker.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcommon.data.mapper.toEither +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.resource.LabelContactEmailsBody +import ch.protonmail.android.mailcontact.data.remote.resource.UnlabelContactEmailsBody +import ch.protonmail.android.mailcontact.data.remote.response.filterUnsuccessful +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.ApiManager +import timber.log.Timber + +@HiltWorker +class EditMembersOfContactGroupWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val apiProvider: ApiProvider, + private val contactGroupLocalDataSource: ContactGroupLocalDataSource +) : CoroutineWorker(context, workerParameters) { + + @Suppress("LongMethod") + override suspend fun doWork(): Result { + + val userId = inputData.getString(RawUserIdKey) + val labelId = inputData.getString(RawLabelIdKey) + val labelContactEmailIds = inputData.getStringArray(RawLabelContactEmailIdsKey) + val unlabelContactEmailIds = inputData.getStringArray(RawUnlabelContactEmailIdsKey) + + @Suppress("ComplexCondition") + if (userId == null || + labelId == null || + @Suppress("UnnecessaryParentheses") + (labelContactEmailIds.isNullOrEmpty() && unlabelContactEmailIds.isNullOrEmpty()) + ) { + return Result.failure() + } + + val api: ApiManager = apiProvider.get(UserId(userId)) + + val labelEither = if (labelContactEmailIds?.isNotEmpty() == true) { + label(api, userId, labelContactEmailIds, labelId) + } else Either.right() + + val unlabelEither = if (unlabelContactEmailIds?.isNotEmpty() == true) { + unlabel(api, userId, unlabelContactEmailIds, labelId) + } else Either.right() + + return if (labelEither.isRight() && unlabelEither.isRight()) { + Result.success() + } else Result.failure() + } + + private suspend fun label( + api: ApiManager, + userId: String, + labelContactEmailIds: Array, + labelId: String + ): Either = either { + val result = api { + labelContactEmails( + LabelContactEmailsBody( + labelId, + labelContactEmailIds.toList() + ) + ) + }.toEither().bind() + + val unsuccessfulContactEmailIds = + result.responses.filterUnsuccessful().map { ContactEmailId(it.contactEmailId) } + + if (unsuccessfulContactEmailIds.isNotEmpty()) { + Timber.e("EditMembersOfContactGroupWorker, label: some ContactEmailIds failed") + contactGroupLocalDataSource.removeContactEmailIdsFromContactGroup( + UserId(userId), + LabelId(labelId), + unsuccessfulContactEmailIds.toSet() + ) + } + } + + private suspend fun unlabel( + api: ApiManager, + userId: String, + unlabelContactEmailIds: Array, + labelId: String + ): Either = either { + val result = api { + unlabelContactEmails( + UnlabelContactEmailsBody( + labelId, + unlabelContactEmailIds.toList() + ) + ) + }.toEither().bind() + + val unsuccessfulContactEmailIds = + result.responses.filterUnsuccessful().map { ContactEmailId(it.contactEmailId) } + + if (unsuccessfulContactEmailIds.isNotEmpty()) { + Timber.e("EditMembersOfContactGroupWorker, unlabel: some ContactEmailIds failed") + contactGroupLocalDataSource.addContactEmailIdsToContactGroup( + UserId(userId), + LabelId(labelId), + unsuccessfulContactEmailIds.toSet() + ) + } + } + + companion object { + + internal const val RawUserIdKey = "userId" + internal const val RawLabelIdKey = "labelId" + internal const val RawLabelContactEmailIdsKey = "labelContactEmailIds" + internal const val RawUnlabelContactEmailIdsKey = "unlabelContactEmailIds" + + fun params( + userId: UserId, + labelId: LabelId, + labelContactEmailIds: Set, + unlabelContactEmailIds: Set + ) = mapOf( + RawUserIdKey to userId.id, + RawLabelIdKey to labelId.id, + RawLabelContactEmailIdsKey to labelContactEmailIds.map { it.id }.toTypedArray(), + RawUnlabelContactEmailIdsKey to unlabelContactEmailIds.map { it.id }.toTypedArray() + ) + + } + +} diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/LabelContactEmailsBody.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/LabelContactEmailsBody.kt new file mode 100644 index 0000000000..f7c4bb08e0 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/LabelContactEmailsBody.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LabelContactEmailsBody( + @SerialName("LabelID") + val labelId: String, + @SerialName("ContactEmailIDs") + val contactEmailIds: List +) diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/UnlabelContactEmailsBody.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/UnlabelContactEmailsBody.kt new file mode 100644 index 0000000000..ad85360638 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/resource/UnlabelContactEmailsBody.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote.resource + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UnlabelContactEmailsBody( + @SerialName("LabelID") + val labelId: String, + @SerialName("ContactEmailIDs") + val contactEmailIds: List +) diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/ContactEmailResponses.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/ContactEmailResponses.kt new file mode 100644 index 0000000000..6cf9f2f38c --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/ContactEmailResponses.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ContactEmailIdResponse( + @SerialName("ID") + val contactEmailId: String, + @SerialName("Response") + val response: ContactEmailCodeResponse +) + +fun List.filterUnsuccessful(): List { + + val responseSuccessCode = 1000 + + return this.filter { + it.response.code != responseSuccessCode + } +} + +@Serializable +data class ContactEmailCodeResponse( + @SerialName("Code") + val code: Int +) diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/LabelContactEmailsResponse.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/LabelContactEmailsResponse.kt new file mode 100644 index 0000000000..474cdec6f2 --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/LabelContactEmailsResponse.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LabelContactEmailsResponse( + @SerialName("Code") + val code: Int, + @SerialName("Responses") + val responses: List +) + diff --git a/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/UnlabelContactEmailsResponse.kt b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/UnlabelContactEmailsResponse.kt new file mode 100644 index 0000000000..b09ed31c6a --- /dev/null +++ b/mail-contact/data/src/main/kotlin/ch/protonmail/android/mailcontact/data/remote/response/UnlabelContactEmailsResponse.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UnlabelContactEmailsResponse( + @SerialName("Code") + val code: Int, + @SerialName("Responses") + val responses: List +) diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImplTest.kt new file mode 100644 index 0000000000..877c8449fc --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactDetailRepositoryImplTest.kt @@ -0,0 +1,60 @@ +package ch.protonmail.android.mailcontact.data + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.data.local.ContactDetailLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactDetailRemoteDataSource +import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository.ContactDetailErrors.ContactDetailLocalDataSourceError +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import kotlin.test.Test +import kotlin.test.assertEquals + +class ContactDetailRepositoryImplTest { + + private val userId = UserId("userId") + private val contactId = ContactId("contactId") + + private val contactDetailLocalDataSource: ContactDetailLocalDataSource = mockk() + private val contactDetailRemoteDataSource: ContactDetailRemoteDataSource = mockk() + + private val contactDetailRepository by lazy { + ContactDetailRepositoryImpl( + contactDetailLocalDataSource, + contactDetailRemoteDataSource + ) + } + + @Test + fun `delete contact should call local and remote data source`() = runTest { + // When + coEvery { contactDetailLocalDataSource.deleteContact(contactId) } returns Unit + every { contactDetailRemoteDataSource.deleteContact(userId, contactId) } returns Unit + val actual = contactDetailRepository.deleteContact(userId, contactId) + + // Then + coVerify { contactDetailLocalDataSource.deleteContact(contactId) } + verify { contactDetailRemoteDataSource.deleteContact(userId, contactId) } + assertEquals(Unit.right(), actual) + } + + @Test + fun `delete contact should return error when local data source fails`() = runTest { + // When + coEvery { contactDetailLocalDataSource.deleteContact(contactId) } throws Exception() + val actual = contactDetailRepository.deleteContact(userId, contactId) + + // Then + coVerify { contactDetailLocalDataSource.deleteContact(contactId) } + verify { contactDetailRemoteDataSource wasNot Called } + assertEquals(ContactDetailLocalDataSourceError.left(), actual) + } + +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImplTest.kt new file mode 100644 index 0000000000..b89a02ac3c --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/ContactGroupRepositoryImplTest.kt @@ -0,0 +1,140 @@ +package ch.protonmail.android.mailcontact.data + +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.sample.LabelIdSample +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.ContactGroupRemoteDataSource +import ch.protonmail.android.mailcontact.domain.repository.ContactGroupRepository +import ch.protonmail.android.testdata.contact.ContactEmailSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress("MaxLineLength") +class ContactGroupRepositoryImplTest { + + private val userId = UserIdTestData.userId + private val labelId = LabelIdSample.LabelCoworkers + private val contactEmailIds = setOf( + ContactEmailSample.contactEmail1.id + ) + + private val contactGroupLocalDataSourceMock: ContactGroupLocalDataSource = mockk() + private val contactGroupRemoteDataSourceMock: ContactGroupRemoteDataSource = mockk() + + private val contactGroupRepository by lazy { + ContactGroupRepositoryImpl( + contactGroupLocalDataSourceMock, + contactGroupRemoteDataSourceMock + ) + } + + @Test + fun `addContactEmailIdsToContactGroup should call local and remote data source`() = runTest { + // Given + coEvery { + contactGroupLocalDataSourceMock.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + every { + contactGroupRemoteDataSourceMock.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + + // When + val actual = contactGroupRepository.addContactEmailIdsToContactGroup(userId, labelId, contactEmailIds) + + // Then + assertEquals(Unit.right(), actual) + } + + @Test + fun `addContactEmailIdsToContactGroup should return RemoteDataSourceError when remote data source fails`() = + runTest { + // Given + coEvery { + contactGroupLocalDataSourceMock.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + every { + contactGroupRemoteDataSourceMock.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIds + ) + } throws Exception() + + // When + val actual = contactGroupRepository.addContactEmailIdsToContactGroup(userId, labelId, contactEmailIds) + + // Then + assertEquals(ContactGroupRepository.ContactGroupErrors.RemoteDataSourceError.left(), actual) + } + + @Test + fun `removeContactEmailIdsFromContactGroup should call local and remote data source`() = runTest { + // Given + coEvery { + contactGroupLocalDataSourceMock.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + every { + contactGroupRemoteDataSourceMock.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + + // When + val actual = contactGroupRepository.removeContactEmailIdsFromContactGroup(userId, labelId, contactEmailIds) + + // Then + assertEquals(Unit.right(), actual) + } + + + @Test + fun `removeContactEmailIdsFromContactGroup should return RemoteDataSourceError when remote data source fails`() = + runTest { + // Given + coEvery { + contactGroupLocalDataSourceMock.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + } returns Unit + every { + contactGroupRemoteDataSourceMock.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIds + ) + } throws Exception() + + // When + val actual = contactGroupRepository.removeContactEmailIdsFromContactGroup(userId, labelId, contactEmailIds) + + // Then + assertEquals(ContactGroupRepository.ContactGroupErrors.RemoteDataSourceError.left(), actual) + } + +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt new file mode 100644 index 0000000000..22d0826407 --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/DeviceContactsRepositoryImplTest.kt @@ -0,0 +1,258 @@ +package ch.protonmail.android.mailcontact.data + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import arrow.core.left +import ch.protonmail.android.mailcontact.domain.repository.DeviceContactsRepository +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.test.kotlin.TestDispatcherProvider +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("MaxLineLength") +class DeviceContactsRepositoryImplTest { + + private val columnIndexDisplayName = 1 + private val columnIndexEmail = 2 + private val displayNameColumnName = ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY + private val emailAddressColumnName = ContactsContract.CommonDataKinds.Email.ADDRESS + + private lateinit var cursorMock: Cursor + + private val contentResolverMock = mockk { } + + private val contextMock = mockk { + every { contentResolver } returns contentResolverMock + } + private val testDispatcherProvider = TestDispatcherProvider() + + private val deviceContactsRepository = DeviceContactsRepositoryImpl( + contextMock, + testDispatcherProvider + ) + + @BeforeTest + fun setup() { + cursorMock = getDefaultCursorMock() + } + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `when there are multiple matching contacts, they are emitted`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.size == 2) + verify(exactly = 2) { cursorMock.getString(columnIndexDisplayName) } + verify(exactly = 2) { cursorMock.getString(columnIndexEmail) } + } + + @Test + fun `when there are no matching contacts, empty list is emitted`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(0) + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.isEmpty()) + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } + } + + @Test + fun `when content resolver throws SecurityException, left is emitted`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQueryThrowsSecurityException() + expectContactsCount(0) + + // When + val actual = deviceContactsRepository.getDeviceContacts(query) + + // Then + assertEquals(DeviceContactsRepository.DeviceContactsErrors.PermissionDenied.left(), actual) + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } + } + + @Test + fun `when content resolver throws a generic exception, left is emitted`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQueryThrowsException() + expectContactsCount(0) + + // When + val actual = deviceContactsRepository.getDeviceContacts(query) + + // Then + assertEquals(DeviceContactsRepository.DeviceContactsErrors.UnknownError.left(), actual) + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } + } + + @Test + fun `when email address column is null, entries are not added`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + + every { cursorMock.getString(columnIndexEmail) } returns null + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.isEmpty()) + verify(exactly = 2) { cursorMock.getString(columnIndexEmail) } + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + } + + @Test + fun `when email address column index is null, entries are not added`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + + every { cursorMock.getColumnIndex(emailAddressColumnName) } returns -1 + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.isEmpty()) + verify(exactly = 0) { cursorMock.getString(columnIndexEmail) } + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + } + + @Test + fun `when null display name column, fall back to the email address`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + + every { cursorMock.getString(columnIndexDisplayName) } returns null + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.size == 2) + assertTrue(actual.all { it.name == it.email }) + verify(exactly = 2) { cursorMock.getString(columnIndexEmail) } + verify(exactly = 2) { cursorMock.getString(columnIndexDisplayName) } + } + + @Test + fun `when null display name column index, fall back to the email address`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + + every { cursorMock.getColumnIndex(displayNameColumnName) } returns -1 + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.size == 2) + assertTrue(actual.all { it.name == it.email }) + verify(exactly = 2) { cursorMock.getString(columnIndexEmail) } + verify(exactly = 0) { cursorMock.getString(columnIndexDisplayName) } + } + + @Test + fun `when cursor can't move to position, then no entry is added`() = runTest(testDispatcherProvider.Main) { + // Given + val query = "cont" + + expectCursorQuery(query) + expectContactsCount(2) + every { cursorMock.moveToPosition(1) } returns false + + // When + val actual = deviceContactsRepository.getDeviceContacts(query).getOrNull() + + // Then + assertNotNull(actual) + assertTrue(actual.size == 1) + verify(exactly = 1) { cursorMock.getString(columnIndexEmail) } + verify(exactly = 1) { cursorMock.getString(columnIndexDisplayName) } + } + + private fun getDefaultCursorMock() = mockk { + every { getColumnIndex(displayNameColumnName) } returns columnIndexDisplayName + every { getColumnIndex(emailAddressColumnName) } returns columnIndexEmail + every { moveToPosition(any()) } returns true + every { isNull(any()) } returns false + every { getString(columnIndexDisplayName) } returns "display name" + every { getString(columnIndexEmail) } returns "email" + every { close() } just runs + } + + private fun expectCursorQuery(query: String) { + every { + contentResolverMock.query(any(), any(), any(), arrayOf("%$query%", "%$query%", "%$query%"), any()) + } returns cursorMock + } + + private fun expectCursorQueryThrowsSecurityException() { + every { + contentResolverMock.query(any(), any(), any(), any(), any()) + } throws SecurityException("You shall not pass") + } + + private fun expectCursorQueryThrowsException() { + every { + contentResolverMock.query(any(), any(), any(), any(), any()) + } throws Exception("You shall not pass either") + } + + private fun expectContactsCount(count: Int) { + every { cursorMock.count } returns count + } +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..c1f56db851 --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactDetailLocalDataSourceImplTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.repository.ContactLocalDataSource +import kotlin.test.Test + +class ContactDetailLocalDataSourceImplTest { + + private val contactLocalDataSource = mockk(relaxUnitFun = true) + + private val contactDetailLocalDataSourceImpl by lazy { ContactDetailLocalDataSourceImpl(contactLocalDataSource) } + + @Test + fun `deleteContact should call contactLocalDataSource deleteContacts`() = runTest { + // Given + val contactId = ContactId("contact_id") + + // When + contactDetailLocalDataSourceImpl.deleteContact(contactId) + + // Then + coVerify { contactLocalDataSource.deleteContacts(contactId) } + } +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImplTest.kt new file mode 100644 index 0000000000..5562927103 --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/local/ContactGroupLocalDataSourceImplTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.local + +import ch.protonmail.android.mailcommon.domain.sample.LabelSample +import ch.protonmail.android.testdata.contact.ContactEmailSample +import ch.protonmail.android.testdata.contact.ContactIdTestData +import ch.protonmail.android.testdata.contact.ContactTestData.buildContactWith +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.contact.domain.repository.ContactLocalDataSource +import kotlin.test.Test + +@Suppress("MaxLineLength") +class ContactGroupLocalDataSourceImplTest { + + private val contactLocalDataSource = mockk(relaxUnitFun = true) { + every { this@mockk.observeAllContacts(UserIdTestData.userId) } returns flowOf( + listOf( + buildContactWith( + UserIdTestData.userId, + ContactIdTestData.contactId1, + listOf( + ContactEmailSample.contactEmail1 + ), + "contact with label: LabelCoworkers" + ) + ) + ) + } + + private val contactGroupLocalDataSourceImpl by lazy { ContactGroupLocalDataSourceImpl(contactLocalDataSource) } + + private val userId = UserIdTestData.userId + private val contactEmailIds = setOf( + ContactEmailSample.contactEmail1.id + ) + + @Test + fun `addContactEmailIdsToContactGroup should not call local repository if Contact is already in a Group`() = + runTest { + // Given + val assignedContactGroupWhichIsADuplicate = LabelSample.GroupCoworkers.labelId + + val expectedContactEmails = emptyList() + + // When + contactGroupLocalDataSourceImpl.addContactEmailIdsToContactGroup( + userId, + assignedContactGroupWhichIsADuplicate, + contactEmailIds + ) + + // Then + coVerify(exactly = 0) { contactLocalDataSource.upsertContactEmails(any()) } + } + + @Test + fun `addContactEmailIdsToContactGroup should call local repository if Contact is not in a newly added Group`() = + runTest { + // Given + val assignedContactGroupWhichIsNew = LabelSample.GroupFriends.labelId + + val expectedContactEmails = listOf( + ContactEmailSample.contactEmail1.copy( + labelIds = ContactEmailSample.contactEmail1.labelIds.plus(assignedContactGroupWhichIsNew.id) + ) + ) + + // When + contactGroupLocalDataSourceImpl.addContactEmailIdsToContactGroup( + userId, + assignedContactGroupWhichIsNew, + contactEmailIds + ) + + // Then + coVerify(exactly = 1) { contactLocalDataSource.upsertContactEmails(*expectedContactEmails.toTypedArray()) } + } + + @Test + fun `removeContactEmailIdsFromContactGroup should not call local repository if Contact is not in a newly removed Group`() = + runTest { + // Given + val removedContactGroupButContactIsNotInIt = LabelSample.GroupFriends.labelId + + val expectedContactEmails = emptyList() + + // When + contactGroupLocalDataSourceImpl.removeContactEmailIdsFromContactGroup( + userId, + removedContactGroupButContactIsNotInIt, + contactEmailIds + ) + + // Then + coVerify(exactly = 0) { contactLocalDataSource.upsertContactEmails(any()) } + } + + @Test + fun `removeContactEmailIdsFromContactGroup should call local repository if Contact is in a newly removed Group`() = + runTest { + // Given + val removedContactGroup = LabelSample.GroupCoworkers.labelId + + val expectedContactEmails = listOf( + ContactEmailSample.contactEmail1.copy( + labelIds = ContactEmailSample.contactEmail1.labelIds.minus(removedContactGroup.id) + ) + ) + + // When + contactGroupLocalDataSourceImpl.removeContactEmailIdsFromContactGroup( + userId, + removedContactGroup, + contactEmailIds + ) + + // Then + coVerify(exactly = 1) { contactLocalDataSource.upsertContactEmails(*expectedContactEmails.toTypedArray()) } + } +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImplTest.kt new file mode 100644 index 0000000000..4e01209096 --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactDetailRemoteDataSourceImplTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import io.mockk.every +import io.mockk.mockk +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import kotlin.test.Test + +class ContactDetailRemoteDataSourceImplTest { + + val userId = UserId("user_id") + val contactId = ContactId("contact_id") + + private val enqueuer: Enqueuer = mockk { + every { + enqueue(userId, DeleteContactWorker.params(userId.id, contactId.id)) + } returns mockk() + } + + private val contactDetailRemoteDataSource = ContactDetailRemoteDataSourceImpl(enqueuer) + + @Test + fun `deleteContact should call enqueuer enqueue`() { + + // When + contactDetailRemoteDataSource.deleteContact(userId, contactId) + + // Then + every { enqueuer.enqueue(userId, DeleteContactWorker.params(userId.id, contactId.id)) } + } + +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImplTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImplTest.kt new file mode 100644 index 0000000000..c0cb82a78f --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/ContactGroupRemoteDataSourceImplTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.testdata.contact.ContactEmailSample +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.serialization.Serializable +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("MaxLineLength") +class ContactGroupRemoteDataSourceImplTest { + + private val userId = UserId("user_id") + private val labelId = LabelId("label_id") + private val contactEmailIds = setOf( + ContactEmailSample.contactEmail1.id + ) + + private val enqueuer: Enqueuer = mockk(relaxed = true) + + private val contactGroupRemoteDataSource = ContactGroupRemoteDataSourceImpl(enqueuer) + + @Test + fun `addContactEmailIdsToContactGroup should enqueue correct Worker`() { + // When + contactGroupRemoteDataSource.addContactEmailIdsToContactGroup(userId, labelId, contactEmailIds) + + // Then + val editMembersOfContactGroupWorkerParams = slot>() + + verify { + enqueuer.enqueue( + userId, + capture(editMembersOfContactGroupWorkerParams) + ) + } + + val capturedUserId = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawUserIdKey] as String + val capturedLabelId = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawLabelIdKey] as String + val capturedAddedLabels = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawLabelContactEmailIdsKey]!! as Array + val capturedRemovedLabels = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawUnlabelContactEmailIdsKey]!! as Array + + assertEquals(userId.id, capturedUserId) + assertEquals(labelId.id, capturedLabelId) + assertEquals(capturedAddedLabels.size, 1) + assertEquals(capturedAddedLabels.first(), contactEmailIds.first().id) + assertTrue(capturedRemovedLabels.isEmpty()) + } + + @Test + fun `removeContactEmailIdsFromContactGroup should enqueue correct Worker`() { + // When + contactGroupRemoteDataSource.removeContactEmailIdsFromContactGroup(userId, labelId, contactEmailIds) + + // Then + val editMembersOfContactGroupWorkerParams = slot>() + + verify { + enqueuer.enqueue( + userId, + capture(editMembersOfContactGroupWorkerParams) + ) + } + + val capturedUserId = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawUserIdKey] as String + val capturedLabelId = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawLabelIdKey] as String + val capturedAddedLabels = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawLabelContactEmailIdsKey]!! as Array + val capturedRemovedLabels = + editMembersOfContactGroupWorkerParams.captured[EditMembersOfContactGroupWorker.Companion.RawUnlabelContactEmailIdsKey]!! as Array + + assertEquals(userId.id, capturedUserId) + assertEquals(labelId.id, capturedLabelId) + assertTrue(capturedAddedLabels.isEmpty()) + assertEquals(capturedRemovedLabels.size, 1) + assertEquals(capturedRemovedLabels.first(), contactEmailIds.first().id) + } + +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorkerTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorkerTest.kt new file mode 100644 index 0000000000..3e5547a6c5 --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/DeleteContactWorkerTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import android.content.Context +import androidx.work.ListenableWorker.Result +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.repository.ContactRemoteDataSource +import me.proton.core.domain.entity.UserId +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeleteContactWorkerTest { + + private val userId = UserId("userId") + private val contactId = ContactId("contactId") + + private val workManager: WorkManager = mockk { + coEvery { enqueue(any()) } returns mockk() + } + private val parameters: WorkerParameters = mockk { + every { taskExecutor } returns mockk(relaxed = true) + every { inputData.getString(DeleteContactWorker.RawUserIdKey) } returns userId.id + every { inputData.getString(DeleteContactWorker.RawContactIdKey) } returns contactId.id + } + private val context: Context = mockk() + private val contactRemoteDataSource: ContactRemoteDataSource = mockk() + private lateinit var worker: DeleteContactWorker + + @BeforeTest + fun setUp() { + worker = DeleteContactWorker(context, parameters, contactRemoteDataSource) + } + + @Test + fun `worker is enqueued with given parameters`() { + // When + Enqueuer(workManager).enqueue(userId, DeleteContactWorker.params(userId.id, contactId.id)) + + // Then + val slot = slot() + verify { workManager.enqueue(capture(slot)) } + val workSpec = slot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val actualUserId = inputData.getString(DeleteContactWorker.RawUserIdKey) + val actualContactId = inputData.getString(DeleteContactWorker.RawContactIdKey) + assertEquals(userId.id, actualUserId) + assertEquals(contactId.id, actualContactId) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + } + + @Test + fun `doWork should return failure when userId is null`() = runTest { + every { parameters.inputData.getString(DeleteContactWorker.RawUserIdKey) } returns null + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return failure when contactId is null`() = runTest { + every { parameters.inputData.getString(DeleteContactWorker.RawContactIdKey) } returns null + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return failure when deleteContacts returns failure`() = runTest { + coEvery { contactRemoteDataSource.deleteContacts(userId, listOf(contactId)) } throws Exception() + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return success when deleteContacts returns success`() = runTest { + coEvery { contactRemoteDataSource.deleteContacts(userId, listOf(contactId)) } returns Unit + + val result = worker.doWork() + + assertEquals(Result.success(), result) + } +} diff --git a/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorkerTest.kt b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorkerTest.kt new file mode 100644 index 0000000000..d1996f530c --- /dev/null +++ b/mail-contact/data/src/test/kotlin/ch/protonmail/android/mailcontact/data/remote/EditMembersOfContactGroupWorkerTest.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.data.remote + +import android.content.Context +import androidx.work.ListenableWorker.Result +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import ch.protonmail.android.mailcommon.data.worker.Enqueuer +import ch.protonmail.android.mailcommon.domain.sample.LabelIdSample +import ch.protonmail.android.mailcontact.data.local.ContactGroupLocalDataSource +import ch.protonmail.android.mailcontact.data.remote.response.ContactEmailCodeResponse +import ch.protonmail.android.mailcontact.data.remote.response.ContactEmailIdResponse +import ch.protonmail.android.mailcontact.data.remote.response.LabelContactEmailsResponse +import ch.protonmail.android.mailcontact.data.remote.response.UnlabelContactEmailsResponse +import ch.protonmail.android.testdata.contact.ContactEmailSample +import ch.protonmail.android.testdata.user.UserIdTestData +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.network.data.ApiProvider +import me.proton.core.network.domain.ApiResult +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress("MaxLineLength") +class EditMembersOfContactGroupWorkerTest { + + private val userId = UserIdTestData.userId + private val labelId = LabelIdSample.LabelCoworkers + private val contactEmailIdsToAdd = setOf( + ContactEmailSample.contactEmail1.id + ) + private val contactEmailIdsToRemove = setOf( + ContactEmailSample.contactEmail2.id + ) + + private val workManager: WorkManager = mockk { + coEvery { enqueue(any()) } returns mockk() + } + private val parameters: WorkerParameters = mockk { + every { taskExecutor } returns mockk(relaxed = true) + every { inputData.getString(EditMembersOfContactGroupWorker.RawUserIdKey) } returns userId.id + every { inputData.getString(EditMembersOfContactGroupWorker.RawLabelIdKey) } returns labelId.id + every { + inputData.getStringArray(EditMembersOfContactGroupWorker.RawLabelContactEmailIdsKey) + } returns contactEmailIdsToAdd.map { it.id } + .toTypedArray() + every { + inputData.getStringArray(EditMembersOfContactGroupWorker.RawUnlabelContactEmailIdsKey) + } returns contactEmailIdsToRemove.map { it.id } + .toTypedArray() + } + private val context: Context = mockk() + + private val contactGroupApi: ContactGroupApi = mockk() + + private val contactGroupLocalDataSourceMock: ContactGroupLocalDataSource = mockk() + + private val apiProvider: ApiProvider = mockk { + coEvery { get(userId).invoke(block = any()) } coAnswers { + val block = firstArg LabelContactEmailsResponse>() + ApiResult.Success(block(contactGroupApi)) + } + } + private lateinit var worker: EditMembersOfContactGroupWorker + + @BeforeTest + fun setUp() { + worker = EditMembersOfContactGroupWorker(context, parameters, apiProvider, contactGroupLocalDataSourceMock) + } + + @Test + fun `worker is enqueued with given parameters`() { + // When + Enqueuer(workManager).enqueue( + userId, + EditMembersOfContactGroupWorker.params( + userId, labelId, contactEmailIdsToAdd, contactEmailIdsToRemove + ) + ) + + // Then + val slot = slot() + verify { workManager.enqueue(capture(slot)) } + val workSpec = slot.captured.workSpec + val constraints = workSpec.constraints + val inputData = workSpec.input + val actualUserId = inputData.getString(EditMembersOfContactGroupWorker.RawUserIdKey) + val actualLabelId = inputData.getString(EditMembersOfContactGroupWorker.RawLabelIdKey) + val actualLabelContactEmailIds = + inputData.getStringArray(EditMembersOfContactGroupWorker.RawLabelContactEmailIdsKey) + val actualUnlabelContactEmailIds = + inputData.getStringArray(EditMembersOfContactGroupWorker.RawUnlabelContactEmailIdsKey) + assertEquals(userId.id, actualUserId) + assertEquals(labelId.id, actualLabelId) + assertEquals(contactEmailIdsToAdd, actualLabelContactEmailIds!!.map { ContactEmailId(it) }.toSet()) + assertEquals(contactEmailIdsToRemove, actualUnlabelContactEmailIds!!.map { ContactEmailId(it) }.toSet()) + assertEquals(NetworkType.CONNECTED, constraints.requiredNetworkType) + } + + @Test + fun `doWork should return failure when userId is null`() = runTest { + every { parameters.inputData.getString(EditMembersOfContactGroupWorker.RawUserIdKey) } returns null + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return failure when labelId is null`() = runTest { + every { parameters.inputData.getString(EditMembersOfContactGroupWorker.RawLabelIdKey) } returns null + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return failure when both labelContactEmailIdsKey and unlabelContactEmailIdsKey are null`() = + runTest { + every { parameters.inputData.getStringArray(EditMembersOfContactGroupWorker.RawLabelContactEmailIdsKey) } returns null + every { parameters.inputData.getStringArray(EditMembersOfContactGroupWorker.RawUnlabelContactEmailIdsKey) } returns null + + val result = worker.doWork() + + assertEquals(Result.failure(), result) + } + + @Test + fun `doWork should return success when labelContactEmails and unlabelContactEmails return success`() = runTest { + // Given + val expectedLabelContactEmailsResponse = LabelContactEmailsResponse( + 1001, + listOf( + ContactEmailIdResponse( + contactEmailIdsToAdd.first().id, + ContactEmailCodeResponse(1000) + ) + ) + ) + coEvery { contactGroupApi.labelContactEmails(any()) } returns expectedLabelContactEmailsResponse + + val expectedUnlabelContactEmailsResponse = UnlabelContactEmailsResponse( + 1001, + listOf( + ContactEmailIdResponse( + contactEmailIdsToRemove.first().id, + ContactEmailCodeResponse(1000) + ) + ) + ) + coEvery { contactGroupApi.unlabelContactEmails(any()) } returns expectedUnlabelContactEmailsResponse + + // When + val result = worker.doWork() + + // Then + assertEquals(Result.success(), result) + } + + @Test + fun `doWork should return success when labelContactEmails succeeds but one of the results failed, and rollback the failed results`() = + runTest { + // Given + val expectedLabelContactEmailsResponse = LabelContactEmailsResponse( + 1001, + listOf( + ContactEmailIdResponse( + ContactEmailSample.contactEmail1.id.id, + ContactEmailCodeResponse(1000) + ), + ContactEmailIdResponse( + ContactEmailSample.contactEmail2.id.id, + ContactEmailCodeResponse(666) // fail + ) + ) + ) + val expectedRolledBackContactEmailId = ContactEmailSample.contactEmail2.id + + coEvery { contactGroupApi.labelContactEmails(any()) } returns expectedLabelContactEmailsResponse + + expectRemoveContactEmailIdsFromContactGroup( + userId, + labelId, + setOf(expectedRolledBackContactEmailId) + ) + + every { + parameters.inputData.getStringArray(EditMembersOfContactGroupWorker.RawUnlabelContactEmailIdsKey) + } returns emptyArray() + + // When + val result = worker.doWork() + + // Then + assertEquals(Result.success(), result) + coVerify(exactly = 1) { + contactGroupLocalDataSourceMock.removeContactEmailIdsFromContactGroup( + userId, + labelId, + setOf(expectedRolledBackContactEmailId) + ) + } + } + + @Test + fun `doWork should return success when unlabelContactEmails succeeds but one of the results failed, and rollback the failed results`() = + runTest { + // Given + every { + parameters.inputData.getStringArray(EditMembersOfContactGroupWorker.RawLabelContactEmailIdsKey) + } returns emptyArray() + + val expectedUnlabelContactEmailsResponse = UnlabelContactEmailsResponse( + 1001, + listOf( + ContactEmailIdResponse( + ContactEmailSample.contactEmail1.id.id, + ContactEmailCodeResponse(1000) + ), + ContactEmailIdResponse( + ContactEmailSample.contactEmail2.id.id, + ContactEmailCodeResponse(666) // fail + ) + ) + ) + val expectedRolledBackContactEmailId = ContactEmailSample.contactEmail2.id + + coEvery { contactGroupApi.unlabelContactEmails(any()) } returns expectedUnlabelContactEmailsResponse + + expectAddContactEmailIdsToContactGroup( + userId, + labelId, + setOf(expectedRolledBackContactEmailId) + ) + + // When + val result = worker.doWork() + + // Then + assertEquals(Result.success(), result) + coVerify(exactly = 1) { + contactGroupLocalDataSourceMock.addContactEmailIdsToContactGroup( + userId, + labelId, + setOf(expectedRolledBackContactEmailId) + ) + } + } + + private fun expectRemoveContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + coEvery { + contactGroupLocalDataSourceMock.removeContactEmailIdsFromContactGroup(userId, labelId, contactEmailIds) + } just Runs + } + + private fun expectAddContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ) { + coEvery { + contactGroupLocalDataSourceMock.addContactEmailIdsToContactGroup(userId, labelId, contactEmailIds) + } just Runs + } + +} diff --git a/mail-contact/domain/build.gradle.kts b/mail-contact/domain/build.gradle.kts new file mode 100644 index 0000000000..5cfeebe05f --- /dev/null +++ b/mail-contact/domain/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "ch.protonmail.android.mailcontact.domain" + compileSdk = Config.compileSdk + + defaultConfig { + minSdk = Config.minSdk + lint.targetSdk = Config.targetSdk + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + implementation(libs.bundles.module.domain) + implementation(libs.kotlin.serialization.json) + + implementation(libs.proton.core.contact) + implementation(libs.proton.core.user) + implementation(libs.proton.core.label) + + implementation(project(":mail-common:domain")) + implementation(project(":mail-label:domain")) + + testImplementation(libs.bundles.test) + testImplementation(libs.proton.core.contact) + testImplementation(project(":test:test-data")) +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/DeviceContactsSuggestionsPrompt.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/DeviceContactsSuggestionsPrompt.kt new file mode 100644 index 0000000000..b4d45b7a9c --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/DeviceContactsSuggestionsPrompt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain + +interface DeviceContactsSuggestionsPrompt { + + suspend fun setPromptDisabled() + + suspend fun getPromptEnabled(): Boolean +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardCryptoExtensions.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardCryptoExtensions.kt new file mode 100644 index 0000000000..07519105fc --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardCryptoExtensions.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain + +import ezvcard.Ezvcard +import ezvcard.VCard +import ezvcard.property.Gender +import me.proton.core.contact.domain.decryptContactCard +import me.proton.core.contact.domain.entity.ContactCard +import me.proton.core.contact.domain.entity.DecryptedVCard +import me.proton.core.crypto.common.pgp.Signature +import me.proton.core.crypto.common.pgp.VerificationStatus +import me.proton.core.key.domain.decryptText +import me.proton.core.key.domain.encryptText +import me.proton.core.key.domain.entity.keyholder.KeyHolderContext +import me.proton.core.key.domain.signText +import me.proton.core.key.domain.verifyText + +fun KeyHolderContext.decryptContactCardTrailingSpacesFallback(contactCard: ContactCard): DecryptedVCard { + return when (contactCard) { + is ContactCard.ClearText -> decryptContactCard(contactCard) + is ContactCard.Encrypted -> decryptContactCardEncryptedTrailingSpacesFallback(contactCard) + is ContactCard.Signed -> decryptContactCardSignedTrailingSpacesFallback(contactCard) + } +} + +@Suppress("FunctionMaxLength") +private fun KeyHolderContext.decryptContactCardSignedTrailingSpacesFallback( + contactCard: ContactCard.Signed +): DecryptedVCard { + val verified = verifyTextWithTrailingSpacesFallback(contactCard.data, contactCard.signature) + return DecryptedVCard( + card = Ezvcard.parse(contactCard.data).first(), + status = VerificationStatus.Success.takeIf { verified } ?: VerificationStatus.Failure + ) +} + +@Suppress("FunctionMaxLength") +private fun KeyHolderContext.decryptContactCardEncryptedTrailingSpacesFallback( + contactCard: ContactCard.Encrypted +): DecryptedVCard { + val decryptedText = decryptText(contactCard.data) + val signature = contactCard.signature + val status = when { + signature == null -> VerificationStatus.NotSigned + verifyTextWithTrailingSpacesFallback(decryptedText, signature) -> VerificationStatus.Success + else -> VerificationStatus.Failure + } + + val parsedVCard = Ezvcard.parse(decryptedText).first() + + return DecryptedVCard( + card = parsedVCard.also { + // hack for MAILANDR-1819 + val extractedGenderValue = decryptedText.extractProperty("GENDER") + if (extractedGenderValue != null) { + it.gender = Gender(extractedGenderValue) + } + }, + status = status + ) +} + +/** + * This fallback should be ported to core. Other functions in this file have been copied from core + * and signature verification replaced with this function that does the fallback. + */ +private fun KeyHolderContext.verifyTextWithTrailingSpacesFallback(data: String, signature: Signature): Boolean { + return if (!verifyText(data, signature, trimTrailingSpaces = true)) { + verifyText(data, signature, trimTrailingSpaces = false) + } else true +} + +fun KeyHolderContext.encryptAndSignNoTrailingSpacesTrim(vCard: VCard): ContactCard.Encrypted { + val vCardData = vCard.write() + val encryptedVCardData = encryptText(vCardData) + val vCardSignature = signText(vCardData, trimTrailingSpaces = false) + return ContactCard.Encrypted(encryptedVCardData, vCardSignature) +} + +fun KeyHolderContext.signNoTrailingSpacesTrim(vCard: VCard): ContactCard.Signed { + val vCardData = vCard.write() + val vCardSignature = signText(vCardData, trimTrailingSpaces = false) + return ContactCard.Signed(vCardData, vCardSignature) +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardExtensions.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardExtensions.kt new file mode 100644 index 0000000000..ba92a31001 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardExtensions.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain + +import ezvcard.VCard +import ezvcard.VCardVersion +import ezvcard.property.Address +import ezvcard.property.Anniversary +import ezvcard.property.Birthday +import ezvcard.property.Categories +import ezvcard.property.Email +import ezvcard.property.FormattedName +import ezvcard.property.Gender +import ezvcard.property.Key +import ezvcard.property.Language +import ezvcard.property.Logo +import ezvcard.property.Member +import ezvcard.property.Nickname +import ezvcard.property.Note +import ezvcard.property.Organization +import ezvcard.property.Photo +import ezvcard.property.ProductId +import ezvcard.property.Related +import ezvcard.property.Role +import ezvcard.property.StructuredName +import ezvcard.property.Telephone +import ezvcard.property.Timezone +import ezvcard.property.Title +import ezvcard.property.Uid +import ezvcard.property.Url +import me.proton.core.util.kotlin.takeIfNotBlank + +/** + * Creates an empty VCard and copies into it only the properties supported by Proton Contacts. + */ +fun VCard.sanitizeAndBuildVCard(): VCard { + + val vCard = VCard(VCardVersion.V4_0).apply { + productId = ProductId(VCARD_PROD_ID) + } + + this.properties.forEach { vCardProperty -> + // we ignore (Version, Prod ID) because we set our own values of them + // but we copy all the supported properties if they were present in the original VCard + when (vCardProperty) { + is Uid, + is FormattedName, + is Email, + is Categories, + is StructuredName, // in VCARD it's a property called "N" + is Telephone, + is Address, + is Birthday, + is Note, + is Photo, + is Organization, + is Title, + is Role, + is Timezone, + is Logo, + is Member, + is Language, + is Url, + is Gender, + is Anniversary, + is Nickname, + is Key, + is Related -> vCard.addProperty(vCardProperty) + } + + } + + this.extendedProperties.forEach { + if ((it.propertyName?.takeIfNotBlank() ?: "").uppercase() in protonSupportedVCardExtendedProperties) { + vCard.extendedProperties.add(it) + } + } + + // if UID is still blank after copying, set a random one + if (vCard.uid?.value?.takeIfNotBlank() == null) { + vCard.uid = Uid.random() + } + + return vCard +} + +const val VCARD_PROD_ID = "-//ProtonMail//ProtonMail for Android vCard 1.0.0//EN" + +val protonSupportedVCardExtendedProperties = listOf( + "X-PM-MIMETYPE", + "X-PM-ENCRYPT", + "X-PM-SIGN", + "X-PM-SCHEME", + "X-PM-TLS", + "X-PM-ENCRYPT-UNTRUSTED" +) diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardUtils.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardUtils.kt new file mode 100644 index 0000000000..69b4be7557 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/VCardUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain + +import java.util.regex.Pattern + +fun String.extractProperty(property: String): String? { + // Regex to match the property with possible line folding + val regex = Pattern.compile( + "$property(?:;[^:]+)*:((?:[^\\r\\n]*(?:\\r\\n|\\n)[ \\t])*(?:[^\\r\\n]+))", + Pattern.CASE_INSENSITIVE + ) + + val matcher = regex.matcher(this) + return if (matcher.find()) { + // Replace folded line continuations with a single space + matcher + .group(1) + ?.replace("\r\n ", "") + ?.replace("\n ", "") + ?.replace("\r\n\t", "") + ?.replace("\n\t", "") + ?.trim() + } else null +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/DecryptedContactMapper.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/DecryptedContactMapper.kt new file mode 100644 index 0000000000..9a162714a8 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/DecryptedContactMapper.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.mapper + +import java.time.ZoneId +import java.util.Date +import ch.protonmail.android.mailcontact.domain.VCARD_PROD_ID +import ch.protonmail.android.mailcontact.domain.model.ContactGroupLabel +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import ezvcard.VCard +import ezvcard.VCardVersion +import ezvcard.parameter.AddressType +import ezvcard.parameter.EmailType +import ezvcard.parameter.ImageType +import ezvcard.parameter.TelephoneType +import ezvcard.property.Address +import ezvcard.property.Anniversary +import ezvcard.property.Birthday +import ezvcard.property.Email +import ezvcard.property.FormattedName +import ezvcard.property.Gender +import ezvcard.property.Logo +import ezvcard.property.Member +import ezvcard.property.Organization +import ezvcard.property.Photo +import ezvcard.property.ProductId +import ezvcard.property.StructuredName +import ezvcard.property.Telephone +import ezvcard.property.Timezone +import ezvcard.property.Title +import me.proton.core.util.kotlin.takeIfNotBlank +import me.proton.core.util.kotlin.takeIfNotEmpty +import javax.inject.Inject + +class DecryptedContactMapper @Inject constructor() { + + /** + * We should not generate ClearText ContactCard if CATEGORIES field is empty. + */ + fun mapToClearTextContactCard(vCard: VCard, contactGroups: List? = null): VCard? { + + val contactGroupNames = contactGroups?.map { it.name }?.toTypedArray() + + return if (contactGroupNames?.isNotEmpty() == true || vCard.categories?.values?.takeIfNotEmpty() != null) { + VCard(VCardVersion.V4_0).apply { + productId = ProductId(VCARD_PROD_ID) + if (contactGroupNames != null) { + setCategories(*contactGroupNames) + } else setCategories(vCard.categories) + } + } else null + } + + fun mapToSignedContactCard( + fallbackName: String, + decryptedContact: DecryptedContact, + vCard: VCard + ): VCard { + return with(vCard) { + + formattedName = decryptedContact.formattedName?.value?.takeIfNotBlank()?.let { + FormattedName(it) + } ?: FormattedName(fallbackName) // API requires every Contact to have FN field + + decryptedContact.emails.let { + emails?.clear() + it.forEachIndexed { index, email -> + addEmail( + Email(email.value).apply { + group = "ITEM${index + 1}" + if (email.type.value.isNotBlank()) { + types.add(EmailType.get(email.type.value)) + } + pref = index + 1 + } + ) + } + } + + this + } + } + + @Suppress("LongMethod", "ComplexMethod") + fun mapToEncryptedAndSignedContactCard(decryptedContact: DecryptedContact, vCard: VCard): VCard { + return with(vCard) { + structuredName = decryptedContact.structuredName?.let { + StructuredName().apply { + family = it.family + given = it.given + } + } + + decryptedContact.telephones.let { + telephoneNumbers?.clear() + it.forEachIndexed { index, telephone -> + addTelephoneNumber( + Telephone(telephone.text).apply { + if (telephone.type.value.isNotBlank()) { + types.add(TelephoneType.get(telephone.type.value)) + } + pref = index + 1 + } + ) + } + } + + decryptedContact.addresses.let { + addresses?.clear() + it.forEachIndexed { index, address -> + addAddress( + Address().apply { + if (address.type.value.isNotBlank()) { + types.add(AddressType.get(address.type.value)) + } + streetAddress = address.streetAddress + locality = address.locality + region = address.region + postalCode = address.postalCode + country = address.country + pref = index + 1 + } + ) + } + } + + birthday = decryptedContact.birthday?.let { + Birthday(Date.from(it.date.atStartOfDay(ZoneId.systemDefault()).toInstant())) + } + + decryptedContact.notes.let { + notes?.clear() + it.forEach { note -> + addNote(note.value) + } + } + + decryptedContact.photos.let { + photos?.clear() + it.forEachIndexed { index, photo -> + addPhoto( + Photo( + photo.data, + ImageType.get(photo.contentType, photo.mediaType, photo.extension) + ).apply { + pref = index + 1 + } + ) + } + } + + decryptedContact.organizations.let { + organizations?.clear() + it.forEach { organization -> + addOrganization( + Organization().apply { + values.add(organization.value) + } + ) + } + } + + decryptedContact.titles.let { + titles?.clear() + it.forEach { title -> + addTitle(Title(title.value)) + } + } + + decryptedContact.roles.let { + roles?.clear() + it.forEach { role -> + addRole(role.value) + } + } + + decryptedContact.timezones.let { + timezones?.clear() + it.forEach { timezone -> + addTimezone(Timezone(timezone.text)) + } + } + + decryptedContact.logos.let { + logos?.clear() + it.forEach { logo -> + addLogo( + Logo( + logo.data, + ImageType.get(logo.contentType, logo.mediaType, logo.extension) + ) + ) + } + } + + decryptedContact.members.let { + members?.clear() + it.forEach { member -> + addMember(Member(member.value)) + } + } + + decryptedContact.languages.let { + languages?.clear() + it.forEach { language -> + addLanguage(language.value) + } + } + + gender = decryptedContact.gender?.let { + Gender(it.gender) + } + + anniversary = decryptedContact.anniversary?.let { + Anniversary(Date.from(it.date.atStartOfDay(ZoneId.systemDefault()).toInstant())) + } + + decryptedContact.urls.let { + urls?.clear() + it.forEach { url -> + addUrl(url.value) + } + } + + this + } + } + +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/ThrowableMapping.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/ThrowableMapping.kt new file mode 100644 index 0000000000..8bcf8a6197 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/mapper/ThrowableMapping.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.mapper + +import me.proton.core.network.domain.hasProtonErrorCode + +fun Throwable.isAlreadyExistsApiError() = this.hasProtonErrorCode(PROTON_RESPONSE_CODE_ALREADY_EXISTS) + +fun Throwable.isContactLimitReachedApiError() = this.hasProtonErrorCode(PROTON_RESPONSE_CODE_CONTACT_LIMIT_REACHED) + +const val PROTON_RESPONSE_CODE_ALREADY_EXISTS = 2500 + +@Suppress("VariableMaxLength") +const val PROTON_RESPONSE_CODE_CONTACT_LIMIT_REACHED = 2024 diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroup.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroup.kt new file mode 100644 index 0000000000..a00c72ddb9 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroup.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId + +data class ContactGroup( + val userId: UserId, + val labelId: LabelId, + val name: String, + val color: String, + val members: List +) diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroupLabel.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroupLabel.kt new file mode 100644 index 0000000000..7a604c8458 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactGroupLabel.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +data class ContactGroupLabel( + val name: String, + val color: String +) diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactProperty.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactProperty.kt new file mode 100644 index 0000000000..ecafeabf6e --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/ContactProperty.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +import java.time.LocalDate + +sealed interface ContactProperty { + + data class StructuredName( + val family: String, + val given: String + ) : ContactProperty + + data class FormattedName( + val value: String + ) : ContactProperty + + data class Email( + val type: Type, + val value: String + ) : ContactProperty { + + enum class Type(val value: String) { + Email(""), + Home("home"), + Work("work"), + Other("other"); + + companion object { + + fun from(value: String?) = values().find { it.value == value } ?: Email + } + } + + } + + data class Photo( + val data: ByteArray, + val contentType: String?, + val mediaType: String?, + val extension: String? + ) : ContactProperty { + + @Suppress("ReturnCount") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Photo + + if (!data.contentEquals(other.data)) return false + if (contentType != other.contentType) return false + if (mediaType != other.mediaType) return false + return extension == other.extension + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + (contentType?.hashCode() ?: 0) + result = 31 * result + (mediaType?.hashCode() ?: 0) + result = 31 * result + (extension?.hashCode() ?: 0) + return result + } + + + } + + data class Birthday( + val date: LocalDate + ) : ContactProperty + + data class Note( + val value: String + ) : ContactProperty + + data class Organization( + val value: String + ) : ContactProperty + + data class Title( + val value: String + ) : ContactProperty + + data class Role( + val value: String + ) : ContactProperty + + data class Timezone( + val text: String + ) : ContactProperty + + data class Logo( + val data: ByteArray, + val contentType: String?, + val mediaType: String?, + val extension: String? + ) : ContactProperty { + + @Suppress("ReturnCount") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Logo + + if (!data.contentEquals(other.data)) return false + if (contentType != other.contentType) return false + if (mediaType != other.mediaType) return false + return extension == other.extension + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + (contentType?.hashCode() ?: 0) + result = 31 * result + (mediaType?.hashCode() ?: 0) + result = 31 * result + (extension?.hashCode() ?: 0) + return result + } + + } + + data class Member( + val value: String + ) : ContactProperty + + data class Language( + val value: String + ) : ContactProperty + + data class Gender( + val gender: String + ) : ContactProperty + + data class Anniversary( + val date: LocalDate + ) : ContactProperty + + data class Url( + val value: String + ) : ContactProperty + + data class Telephone( + val type: Type, + val text: String + ) : ContactProperty { + + enum class Type(val value: String) { + Telephone(""), + Home("home"), + Work("work"), + Other("other"), + Mobile("mobile"), + Main("main"), + Fax("fax"), + Pager("pager"); + + companion object { + + fun from(value: String?) = values().find { it.value == value } ?: Telephone + } + } + } + + data class Address( + val type: Type, + val streetAddress: String, + val locality: String, + val region: String, + val postalCode: String, + val country: String + ) : ContactProperty { + + enum class Type(val value: String) { + Address(""), + Home("home"), + Work("work"), + Other("other"); + + companion object { + + fun from(value: String?) = values().find { it.value == value } ?: Address + } + } + + } + +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DecryptedContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DecryptedContact.kt new file mode 100644 index 0000000000..484ac85cb8 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DecryptedContact.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +import me.proton.core.contact.domain.entity.ContactId + +data class DecryptedContact( + val id: ContactId?, + val contactGroupLabels: List = emptyList(), + val structuredName: ContactProperty.StructuredName? = null, + val formattedName: ContactProperty.FormattedName? = null, + val emails: List = emptyList(), + val telephones: List = emptyList(), + val addresses: List = emptyList(), + val birthday: ContactProperty.Birthday? = null, + val notes: List = emptyList(), + val photos: List = emptyList(), + val organizations: List = emptyList(), + val titles: List = emptyList(), + val roles: List = emptyList(), + val timezones: List = emptyList(), + val logos: List = emptyList(), + val members: List = emptyList(), + val languages: List = emptyList(), + val urls: List = emptyList(), + val gender: ContactProperty.Gender? = null, + val anniversary: ContactProperty.Anniversary? = null +) diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DeviceContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DeviceContact.kt new file mode 100644 index 0000000000..24bafd8d2b --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/DeviceContact.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +data class DeviceContact( + val name: String, + val email: String +) diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/GetContactError.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/GetContactError.kt new file mode 100644 index 0000000000..6d7675af35 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/model/GetContactError.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.model + +object GetContactError diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactDetailRepository.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactDetailRepository.kt new file mode 100644 index 0000000000..a51c92e6f7 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactDetailRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.repository + +import arrow.core.Either +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId + +interface ContactDetailRepository { + + suspend fun deleteContact(userId: UserId, contactId: ContactId): Either + + sealed class ContactDetailErrors { + object ContactDetailLocalDataSourceError : ContactDetailErrors() + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactGroupRepository.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactGroupRepository.kt new file mode 100644 index 0000000000..9ae960b082 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/ContactGroupRepository.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.repository + +import arrow.core.Either +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId + +interface ContactGroupRepository { + + suspend fun addContactEmailIdsToContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ): Either + + suspend fun removeContactEmailIdsFromContactGroup( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ): Either + + sealed class ContactGroupErrors { + object RemoteDataSourceError : ContactGroupErrors() + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt new file mode 100644 index 0000000000..8da24da3e0 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/repository/DeviceContactsRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.repository + +import arrow.core.Either +import ch.protonmail.android.mailcontact.domain.model.DeviceContact + +interface DeviceContactsRepository { + + suspend fun getDeviceContacts(query: String): Either> + + sealed class DeviceContactsErrors { + data object PermissionDenied : DeviceContactsErrors() + data object UnknownError : DeviceContactsErrors() + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContact.kt new file mode 100644 index 0000000000..e7593f7f3d --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContact.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import ch.protonmail.android.mailcontact.domain.mapper.isContactLimitReachedApiError +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class CreateContact @Inject constructor( + private val contactRepository: ContactRepository, + private val encryptAndSignContactCards: EncryptAndSignContactCards +) { + + suspend operator fun invoke(userId: UserId, decryptedContact: DecryptedContact): Either { + val contactCards = encryptAndSignContactCards.invoke(userId, decryptedContact).getOrElse { + return CreateContactErrors.FailedToEncryptAndSignContactCards.left() + } + return Either.catch { + contactRepository.createContact(userId, contactCards) + }.mapLeft { + if (it.isContactLimitReachedApiError()) { + CreateContactErrors.MaximumNumberOfContactsReached + } else CreateContactErrors.FailedToCreateContact + } + } + + sealed interface CreateContactErrors { + data object FailedToEncryptAndSignContactCards : CreateContactErrors + data object FailedToCreateContact : CreateContactErrors + data object MaximumNumberOfContactsReached : CreateContactErrors + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContactGroup.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContactGroup.kt new file mode 100644 index 0000000000..1f9b206dda --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/CreateContactGroup.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcontact.domain.mapper.isAlreadyExistsApiError +import ch.protonmail.android.maillabel.domain.model.ColorRgbHex +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelType +import me.proton.core.label.domain.entity.NewLabel +import me.proton.core.label.domain.repository.LabelRepository +import javax.inject.Inject + +class CreateContactGroup @Inject constructor( + private val labelRepository: LabelRepository, + private val editContactGroupMembers: EditContactGroupMembers +) { + + suspend operator fun invoke( + userId: UserId, + name: String, + color: ColorRgbHex, + contactEmailIds: List + ): Either = either { + + val label = NewLabel( + name = name.trim(), + color = color.hex, + isNotified = null, + isExpanded = null, + isSticky = null, + parentId = null, + type = LabelType.ContactGroup + ) + + runCatching { + labelRepository.createLabel(userId, label) + }.getOrElse { + if (it.isAlreadyExistsApiError()) { + raise(CreateContactGroupError.GroupNameDuplicate) + } else raise(CreateContactGroupError.CreatingLabelError) + } + + val createdLabel = labelRepository.getLabels(userId, LabelType.ContactGroup, refresh = true).find { + it.name == label.name + } ?: raise(CreateContactGroupError.CreatingLabelError) + + return editContactGroupMembers( + userId, + createdLabel.labelId, + contactEmailIds.toSet() + ).mapLeft { + CreateContactGroupError.EditingMembersError + } + } +} + +sealed class CreateContactGroupError { + object CreatingLabelError : CreateContactGroupError() + object GroupNameDuplicate : CreateContactGroupError() + object EditingMembersError : CreateContactGroupError() +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DecryptContactCards.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DecryptContactCards.kt new file mode 100644 index 0000000000..79ea272806 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DecryptContactCards.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.decryptContactCardTrailingSpacesFallback +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import me.proton.core.contact.domain.entity.ContactWithCards +import me.proton.core.contact.domain.entity.DecryptedVCard +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.UserManager +import timber.log.Timber +import javax.inject.Inject + +/** + * Decrypts and verifies ContactVCards. + */ +class DecryptContactCards @Inject constructor( + private val userManager: UserManager, + private val cryptoContext: CryptoContext +) { + + suspend operator fun invoke( + userId: UserId, + contactWithCards: ContactWithCards + ): Either> = either { + val user = userManager.getUser(userId) + return user.useKeys(cryptoContext) { + contactWithCards.contactCards.map { card -> + runCatching { + decryptContactCardTrailingSpacesFallback(card) + }.getOrElse { + Timber.e("Exception decrypting Contact VCard", it) + raise(GetContactError) + } + } + }.right() + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContact.kt new file mode 100644 index 0000000000..89578c061d --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContact.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import ch.protonmail.android.mailcontact.domain.repository.ContactDetailRepository +import ch.protonmail.android.mailcontact.domain.usecase.DeleteContact.DeleteContactErrors.FailedToDeleteContact +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class DeleteContact @Inject constructor(private val contactDetailRepository: ContactDetailRepository) { + + suspend operator fun invoke(userId: UserId, contactId: ContactId): Either = + contactDetailRepository.deleteContact(userId, contactId).mapLeft { FailedToDeleteContact } + + sealed interface DeleteContactErrors { + object FailedToDeleteContact : DeleteContactErrors + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContactGroup.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContactGroup.kt new file mode 100644 index 0000000000..2ae025af89 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/DeleteContactGroup.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.maillabel.domain.usecase.DeleteLabel +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.label.domain.entity.LabelType +import javax.inject.Inject + +class DeleteContactGroup @Inject constructor( + private val deleteLabel: DeleteLabel +) { + + suspend operator fun invoke(userId: UserId, labelId: LabelId): Either = either { + deleteLabel.invoke(userId, labelId, LabelType.ContactGroup) + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContact.kt new file mode 100644 index 0000000000..6f91f562d7 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContact.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class EditContact @Inject constructor( + private val contactRepository: ContactRepository, + private val encryptAndSignContactCards: EncryptAndSignContactCards +) { + + suspend operator fun invoke( + userId: UserId, + decryptedContact: DecryptedContact, + contactId: ContactId + ): Either { + val contactCards = encryptAndSignContactCards( + userId, + decryptedContact.takeIf { it.id != null } ?: decryptedContact.copy( + id = contactId + ) + ).getOrElse { + return EditContactErrors.FailedToEncryptAndSignContactCards.left() + } + return Either.catch { + contactRepository.updateContact(userId, contactId, contactCards) + }.mapLeft { + EditContactErrors.FailedToEditContact + } + } + + sealed interface EditContactErrors { + object FailedToEncryptAndSignContactCards : EditContactErrors + object FailedToEditContact : EditContactErrors + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroup.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroup.kt new file mode 100644 index 0000000000..7108ddfc01 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroup.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.maillabel.domain.model.ColorRgbHex +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.label.domain.entity.LabelType +import me.proton.core.label.domain.repository.LabelRepository +import javax.inject.Inject + +class EditContactGroup @Inject constructor( + private val labelRepository: LabelRepository, + private val editContactGroupMembers: EditContactGroupMembers +) { + + suspend operator fun invoke( + userId: UserId, + labelId: LabelId, + name: String, + color: ColorRgbHex, + contactEmailIds: List + ): Either = either { + + val contactGroupLabel = labelRepository.getLabel(userId, LabelType.ContactGroup, labelId) ?: raise( + EditContactGroupError.LabelNotFound + ) + + labelRepository.updateLabel( + userId, + contactGroupLabel.copy( + name = name.trim(), + color = color.hex + ) + ) + + return editContactGroupMembers( + userId, + labelId, + contactEmailIds.toSet() + ).mapLeft { + EditContactGroupError.EditingMembersError + } + } + +} + +sealed class EditContactGroupError { + object LabelNotFound : EditContactGroupError() + object EditingMembersError : EditContactGroupError() +} + diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroupMembers.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroupMembers.kt new file mode 100644 index 0000000000..0085900239 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EditContactGroupMembers.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.raise.either +import ch.protonmail.android.mailcontact.domain.repository.ContactGroupRepository +import kotlinx.coroutines.flow.first +import me.proton.core.contact.domain.entity.ContactEmailId +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import timber.log.Timber +import javax.inject.Inject + +class EditContactGroupMembers @Inject constructor( + private val observeContactGroup: ObserveContactGroup, + private val contactGroupRepository: ContactGroupRepository +) { + + suspend operator fun invoke( + userId: UserId, + labelId: LabelId, + contactEmailIds: Set + ): Either = either { + + val contactGroup = observeContactGroup(userId, labelId).first().getOrElse { + Timber.e("Error while observing contact group by id in EditContactGroupMembers") + raise(EditContactGroupMembersError.ObservingContactGroup) + } + + val groupContactEmailIds = contactGroup.members.map { it.id }.toSet() + val contactEmailIdsToAdd = contactEmailIds.subtract(groupContactEmailIds) + val contactEmailIdsToRemove = groupContactEmailIds.subtract(contactEmailIds) + + if (contactEmailIdsToAdd.isNotEmpty()) { + contactGroupRepository.addContactEmailIdsToContactGroup( + userId, + labelId, + contactEmailIdsToAdd + ).onLeft { + Timber.e("Error while adding Members in EditContactGroupMembers") + raise(EditContactGroupMembersError.AddingContactsToGroup) + } + } + + if (contactEmailIdsToRemove.isNotEmpty()) { + contactGroupRepository.removeContactEmailIdsFromContactGroup( + userId, + labelId, + contactEmailIdsToRemove + ).onLeft { + Timber.e("Error while removing Members in EditContactGroupMembers") + raise(EditContactGroupMembersError.RemovingContactsFromGroup) + } + } + } + + sealed interface EditContactGroupMembersError { + object ObservingContactGroup : EditContactGroupMembersError + object AddingContactsToGroup : EditContactGroupMembersError + object RemovingContactsFromGroup : EditContactGroupMembersError + } + +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EncryptAndSignContactCards.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EncryptAndSignContactCards.kt new file mode 100644 index 0000000000..b41007e8c7 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/EncryptAndSignContactCards.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcommon.domain.mapper.mapToEither +import ch.protonmail.android.mailcontact.domain.encryptAndSignNoTrailingSpacesTrim +import ch.protonmail.android.mailcontact.domain.mapper.DecryptedContactMapper +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import ch.protonmail.android.mailcontact.domain.sanitizeAndBuildVCard +import ch.protonmail.android.mailcontact.domain.signNoTrailingSpacesTrim +import ezvcard.VCard +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.contact.domain.entity.ContactCard +import me.proton.core.contact.domain.entity.ContactWithCards +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.crypto.common.context.CryptoContext +import me.proton.core.crypto.common.pgp.VerificationStatus +import me.proton.core.domain.entity.UserId +import me.proton.core.key.domain.useKeys +import me.proton.core.user.domain.UserManager +import me.proton.core.util.kotlin.takeIfNotEmpty +import javax.inject.Inject + +class EncryptAndSignContactCards @Inject constructor( + private val userManager: UserManager, + private val cryptoContext: CryptoContext, + private val contactRepository: ContactRepository, + private val decryptContactCards: DecryptContactCards, + private val decryptedContactMapper: DecryptedContactMapper +) { + + suspend operator fun invoke( + userId: UserId, + decryptedContact: DecryptedContact + ): Either> = either { + // retrieve original ContactCards + val contactWithCards = decryptedContact.id?.let { + contactRepository.observeContactWithCards( + userId, + decryptedContact.id + ).mapToEither().firstOrNull()?.getOrNull() ?: raise(EncryptingContactCardsError.ContactNotFoundInDB) + } + + // decrypt them and check signatures + val cardsToDecryptedCards = decryptContactCardsOneByOne( + contactWithCards, + this@EncryptAndSignContactCards, + userId, + this + ) + + val clearTextContactCard = cardsToDecryptedCards?.find { + it.first is ContactCard.ClearText + }?.second?.card + + val signedContactCard = cardsToDecryptedCards?.find { + it.first is ContactCard.Signed + }?.second?.card + + val encryptedAndSignedContactCard = cardsToDecryptedCards?.find { + it.first is ContactCard.Encrypted + }?.second?.card + + val fallbackName = getFallbackName(contactWithCards, decryptedContact) + + // insert all properties from DecryptedContact where they belong inside the ContactCards, encrypt and sign + val encryptedAndSignedContactCards = userManager.getUser(userId).useKeys(cryptoContext) { + listOfNotNull( + decryptedContactMapper.mapToClearTextContactCard( + (clearTextContactCard ?: VCard()).sanitizeAndBuildVCard(), + decryptedContact.contactGroupLabels + )?.let { ContactCard.ClearText(it.write()) }, + decryptedContactMapper.mapToSignedContactCard( + fallbackName, + decryptedContact, + (signedContactCard ?: VCard()).sanitizeAndBuildVCard() + ).let { signNoTrailingSpacesTrim(it) }, + decryptedContactMapper.mapToEncryptedAndSignedContactCard( + decryptedContact, + (encryptedAndSignedContactCard ?: VCard()).sanitizeAndBuildVCard() + ).let { encryptAndSignNoTrailingSpacesTrim(it) } + ) + } + + return encryptedAndSignedContactCards.right() + } + + // generate fallback name in an unlikely case the Signed ContactCard doesn't contain it + // and it's not provided in our DecryptedContact + private fun Raise.getFallbackName( + contactWithCards: ContactWithCards?, + decryptedContact: DecryptedContact + ) = contactWithCards?.contact?.name + ?: decryptedContact.formattedName?.value?.takeIfNotEmpty() + ?: decryptedContact.structuredName?.let { + it.given.plus(" ${it.family}") + } ?: raise(EncryptingContactCardsError.MissingFormattedName) + + private suspend fun decryptContactCardsOneByOne( + contactWithCards: ContactWithCards?, + encryptAndSignContactCards: EncryptAndSignContactCards, + userId: UserId, + raise: Raise + ) = contactWithCards?.contactCards?.mapNotNull { contactCard -> + val decryptedContactCard = encryptAndSignContactCards.decryptContactCards( + userId, + contactWithCards.copy( + // pass only one ContactCard so we don't lose the relation before- and -after decryption + contactCards = listOf(contactCard) + ) + ).onLeft { + raise.raise(EncryptingContactCardsError.DecryptingContactCardError) + }.getOrNull()?.firstOrNull() + + decryptedContactCard?.let { contactCard to it } + }?.filter { + // only take the correctly signed ones + it.second.status == VerificationStatus.Success || it.second.status == VerificationStatus.NotSigned + } + +} + +sealed class EncryptingContactCardsError { + object ContactNotFoundInDB : EncryptingContactCardsError() + object DecryptingContactCardError : EncryptingContactCardsError() + object MissingFormattedName : EncryptingContactCardsError() +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/FindContactByEmail.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/FindContactByEmail.kt new file mode 100644 index 0000000000..a2466e6cd5 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/FindContactByEmail.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class FindContactByEmail @Inject constructor( + private val getContacts: GetContacts +) { + + suspend operator fun invoke(userId: UserId, emailAddress: String): Contact? = getContacts(userId).fold( + ifLeft = { null }, + ifRight = { contacts -> + contacts.firstOrNull { contact -> + contact.contactEmails.any { it.email.equals(emailAddress, ignoreCase = true) } + } + } + ) +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContactEmailsById.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContactEmailsById.kt new file mode 100644 index 0000000000..1936e9ef52 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContactEmailsById.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class GetContactEmailsById @Inject constructor( + private val observeContacts: ObserveContacts +) { + + suspend operator fun invoke( + userId: UserId, + selectedContactEmailIds: List + ): Either> { + return observeContacts(userId).firstOrNull()?.getOrNull()?.flatMap { contact -> + contact.contactEmails.mapNotNull { contactEmail -> + contactEmail.takeIf { selectedContactEmailIds.contains(contactEmail.id.id) } + } + }?.right() ?: GetContactError.left() + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContacts.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContacts.kt new file mode 100644 index 0000000000..f088001e08 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetContacts.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import kotlinx.coroutines.flow.firstOrNull +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class GetContacts @Inject constructor( + private val observeContacts: ObserveContacts +) { + + suspend operator fun invoke(userId: UserId): Either> = + observeContacts(userId).firstOrNull()?.getOrNull()?.right() ?: GetContactError.left() + +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetDecryptedContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetDecryptedContact.kt new file mode 100644 index 0000000000..80ba8c9b86 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/GetDecryptedContact.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import java.time.ZoneId +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import ch.protonmail.android.mailcontact.domain.model.ContactProperty +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import ezvcard.property.Address +import ezvcard.property.Anniversary +import ezvcard.property.Birthday +import ezvcard.property.Email +import ezvcard.property.FormattedName +import ezvcard.property.Gender +import ezvcard.property.Language +import ezvcard.property.Logo +import ezvcard.property.Member +import ezvcard.property.Note +import ezvcard.property.Organization +import ezvcard.property.Photo +import ezvcard.property.Role +import ezvcard.property.StructuredName +import ezvcard.property.Telephone +import ezvcard.property.Timezone +import ezvcard.property.Title +import ezvcard.property.Url +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.entity.ContactWithCards +import me.proton.core.contact.domain.entity.DecryptedVCard +import me.proton.core.crypto.common.pgp.VerificationStatus +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +/** + * Decrypts ContactCards and combines all the data into one [DecryptedContact] model. + */ +class GetDecryptedContact @Inject constructor( + private val decryptContactCards: DecryptContactCards +) { + + suspend operator fun invoke( + userId: UserId, + contactWithCards: ContactWithCards + ): Either = either { + + val decryptedCards = decryptContactCards(userId, contactWithCards).bind().filter { + it.status == VerificationStatus.Success || it.status == VerificationStatus.NotSigned + } + + return extractFromVCards(contactWithCards.id, decryptedCards).right() + } + + @Suppress("LongMethod", "ComplexMethod") + private fun extractFromVCards(contactId: ContactId, decryptedCards: List): DecryptedContact { + + var decryptedContact = DecryptedContact(contactId) + + decryptedCards.forEach { decryptedVCard -> + decryptedVCard.card.properties.forEach { + decryptedContact = when (it) { + is StructuredName -> { + decryptedContact.copy( + structuredName = ContactProperty.StructuredName( + it.family ?: "", + it.given ?: "" + ) + ) + } + + is FormattedName -> { + decryptedContact.copy( + formattedName = ContactProperty.FormattedName( + it.value ?: "" + ) + ) + } + + is Email -> { + decryptedContact.copy( + emails = decryptedContact.emails + ContactProperty.Email( + type = ContactProperty.Email.Type.from(it.types.firstOrNull()?.value), + value = it.value ?: "" + ) + ) + } + + is Telephone -> { + decryptedContact.copy( + telephones = decryptedContact.telephones + ContactProperty.Telephone( + type = ContactProperty.Telephone.Type.from(it.types.firstOrNull()?.value), + text = it.text ?: "" + ) + ) + } + + is Address -> { + decryptedContact.copy( + addresses = decryptedContact.addresses + ContactProperty.Address( + type = ContactProperty.Address.Type.from(it.types.firstOrNull()?.value), + streetAddress = it.streetAddress ?: "", + locality = it.locality ?: "", + region = it.region ?: "", + postalCode = it.postalCode ?: "", + country = it.country ?: "" + ) + ) + } + + is Birthday -> { + if (it.date != null) { + decryptedContact.copy( + birthday = ContactProperty.Birthday( + it.date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + ) + ) + } else decryptedContact + } + + is Note -> { + decryptedContact.copy( + notes = decryptedContact.notes + ContactProperty.Note( + it.value ?: "" + ) + ) + } + + is Photo -> { + decryptedContact.copy( + photos = decryptedContact.photos + ContactProperty.Photo( + data = it.data ?: ByteArray(0), + contentType = it.contentType?.value, + mediaType = it.contentType?.mediaType, + extension = it.contentType?.extension + ) + ) + } + + is Organization -> { + decryptedContact.copy( + organizations = decryptedContact.organizations + it.values.map { + ContactProperty.Organization(it) + } + ) + } + + is Title -> { + decryptedContact.copy( + titles = decryptedContact.titles + ContactProperty.Title( + it.value ?: "" + ) + ) + } + + is Role -> { + decryptedContact.copy( + roles = decryptedContact.roles + ContactProperty.Role( + it.value ?: "" + ) + ) + } + + is Timezone -> { + decryptedContact.copy( + timezones = decryptedContact.timezones + ContactProperty.Timezone( + it.text ?: "" + ) + ) + } + + is Logo -> { + decryptedContact.copy( + logos = decryptedContact.logos + ContactProperty.Logo( + data = it.data ?: ByteArray(0), + contentType = it.contentType?.value, + mediaType = it.contentType?.mediaType, + extension = it.contentType?.extension + ) + ) + } + + is Member -> { + decryptedContact.copy( + members = decryptedContact.members + ContactProperty.Member( + it.value ?: "" + ) + ) + } + + is Language -> { + decryptedContact.copy( + languages = decryptedContact.languages + ContactProperty.Language( + it.value ?: "" + ) + ) + } + + is Gender -> { + decryptedContact.copy( + gender = ContactProperty.Gender( + it.gender ?: "" + ) + ) + } + + is Anniversary -> { + if (it.date != null) { + decryptedContact.copy( + anniversary = ContactProperty.Anniversary( + it.date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + ) + ) + } else decryptedContact + } + + is Url -> { + decryptedContact.copy( + urls = decryptedContact.urls + ContactProperty.Url( + it.value ?: "" + ) + ) + } + + else -> { + decryptedContact + } + } + } + } + + return decryptedContact + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroup.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroup.kt new file mode 100644 index 0000000000..1b4a808dcc --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroup.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.mapper.mapToEither +import ch.protonmail.android.mailcontact.domain.model.ContactGroup +import ch.protonmail.android.maillabel.domain.usecase.ObserveLabels +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import me.proton.core.contact.domain.entity.ContactEmail +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.LabelId +import me.proton.core.label.domain.entity.LabelType +import javax.inject.Inject + +class ObserveContactGroup @Inject constructor( + private val observeLabels: ObserveLabels, + private val contactRepository: ContactRepository +) { + + operator fun invoke(userId: UserId, labelId: LabelId): Flow> { + return combine( + observeLabels(userId, LabelType.ContactGroup), + contactRepository.observeAllContacts(userId).mapToEither() + ) { labels, contacts -> + either { + val label = labels.getOrNull()?.firstOrNull { + it.labelId == labelId + } ?: raise(GetContactGroupError.GetLabelsError) + + val contactGroupMembers = arrayListOf() + contacts.getOrNull()?.forEach { contact -> + contact.contactEmails.forEach { contactEmail -> + if (contactEmail.labelIds.contains(labelId.id)) contactGroupMembers.add(contactEmail) + } + } ?: raise(GetContactGroupError.GetContactsError) + + ContactGroup( + userId = label.userId, + labelId = labelId, + name = label.name, + color = label.color, + members = contactGroupMembers + ) + } + } + } +} + +sealed interface GetContactGroupError { + object GetLabelsError : GetContactGroupError + object GetContactsError : GetContactGroupError +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroupLabels.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroupLabels.kt new file mode 100644 index 0000000000..92aa16bfb7 --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContactGroupLabels.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import ch.protonmail.android.maillabel.domain.usecase.ObserveLabels +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.Label +import me.proton.core.label.domain.entity.LabelType +import javax.inject.Inject + +class ObserveContactGroupLabels @Inject constructor( + private val observeLabels: ObserveLabels +) { + + operator fun invoke(userId: UserId): Flow>> = + observeLabels(userId, LabelType.ContactGroup) + .mapLatest { + it.mapLeft { GetContactGroupLabelsError } + } +} + +object GetContactGroupLabelsError diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContacts.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContacts.kt new file mode 100644 index 0000000000..9fb20d72bb --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveContacts.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import ch.protonmail.android.mailcommon.domain.mapper.mapToEither +import ch.protonmail.android.mailcontact.domain.model.GetContactError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import me.proton.core.contact.domain.entity.Contact +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.entity.UserId +import javax.inject.Inject + +class ObserveContacts @Inject constructor( + private val contactRepository: ContactRepository +) { + + operator fun invoke(userId: UserId): Flow>> = + contactRepository.observeAllContacts(userId) + .mapToEither() + .mapLatest { + it.mapLeft { GetContactError } + } +} diff --git a/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveDecryptedContact.kt b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveDecryptedContact.kt new file mode 100644 index 0000000000..414995774e --- /dev/null +++ b/mail-contact/domain/src/main/kotlin/ch/protonmail/android/mailcontact/domain/usecase/ObserveDecryptedContact.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 Proton Technologies AG + * This file is part of Proton Technologies AG and Proton Mail. + * + * Proton Mail is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Proton Mail is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Proton Mail. If not, see . + */ + +package ch.protonmail.android.mailcontact.domain.usecase + +import arrow.core.Either +import arrow.core.raise.either +import ch.protonmail.android.mailcommon.domain.mapper.mapToEither +import ch.protonmail.android.mailcommon.domain.model.DataError +import ch.protonmail.android.mailcontact.domain.model.ContactGroupLabel +import ch.protonmail.android.mailcontact.domain.model.DecryptedContact +import ch.protonmail.android.maillabel.domain.usecase.ObserveLabels +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import me.proton.core.contact.domain.entity.ContactId +import me.proton.core.contact.domain.entity.ContactWithCards +import me.proton.core.contact.domain.repository.ContactRepository +import me.proton.core.domain.entity.UserId +import me.proton.core.label.domain.entity.Label +import me.proton.core.label.domain.entity.LabelType +import javax.inject.Inject + +class ObserveDecryptedContact @Inject constructor( + private val contactRepository: ContactRepository, + private val getDecryptedContact: GetDecryptedContact, + private val observeLabels: ObserveLabels +) { + + operator fun invoke( + userId: UserId, + contactId: ContactId, + refresh: Boolean = false + ): Flow> { + return combine( + contactRepository.observeContactWithCards(userId, contactId, refresh).mapToEither(), + observeLabels(userId, LabelType.ContactGroup) + ) { contactWithCardsEither, labelsEither -> + either { + + val contactWithCards = contactWithCardsEither.bind() + val allContactLabels = labelsEither.bind() + + getDecryptedContact(userId, contactWithCards).getOrNull()?.copy( + contactGroupLabels = createContactGroups(contactWithCards, allContactLabels) + ) ?: raise(DataError.Local.DecryptionError) + } + } + } + + private fun createContactGroups( + contactWithCards: ContactWithCards, + allContactLabels: List